feat(cw): add 20s rolling buffer with periodic full re-decode
DeepCW 是整段 CTC 批处理模型, 不能像传统 DSP 那样逐样本流式输出。 实测否决了滑动窗口+增量拼接方案。整段批处理 CER 0.0%, 而所有窗口/步进 组合最好仅 67.6%, 最差 294.1% (呼号被重复三遍)。根因是模型每获得更多 上下文就重写已输出内容 —— 同一音频逐渐加长时 11 次采样有 6 次修正前缀 (BM -> BG7 -> BG7NTI -> BG7NTA), 任何"只追加增量"策略都会把这些中间态 永久留在屏幕上。 改为: 固定 20 秒滚动缓冲 + 每 1.5 秒重解全量 + 整体替换显示。 参数由实测定案, 是两条约束的交点: - 准确率侧: 20s 是达到 0.0% CER 的最小窗口 (16s 仍有 5.9%) - 算力侧: 耗时超线性增长, 60s 音频推理耗时约为 20s 的 13 倍, 实时余量 不足; 20s 时余量约 13 倍, 安全 实现: 环形缓冲, snapshot() 返回按时序展开的副本 (调用方无法污染内部 存储), append() 返回是否该触发重解。 验证: ./gradlew :core:domain:test --tests '*CwDeepBufferTest*' => tests="10" skipped="0" failures="0" errors="0" 含断言: 容量封顶、最旧样本先丢、环绕后时序正确、超容量分块只留尾部、 重解间隔不漂移 (150 个 100ms 分块恰好触发 10 次)、snapshot 不共享内存
This commit is contained in:
1 parent
0d3e02c596
commit
6a62f951ad
2 files changed
+229
No files matched your search
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Bounded rolling audio buffer that drives periodic full re-decodes.
|
||||
*
|
||||
* DeepCW is a whole-segment CTC model. It rewrites earlier output as more
|
||||
* context arrives — measured on one clip, 6 of 11 progressively longer reads
|
||||
* revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only
|
||||
* the newest fragment therefore leaves those intermediate guesses on screen
|
||||
* forever; measured character error rate for sliding-window stitching ranged
|
||||
* from 67% to 294%, against 0% for decoding the whole segment at once.
|
||||
*
|
||||
* So we keep a fixed window, re-run the model over all of it every
|
||||
* [redecodeIntervalMs], and replace the displayed text outright.
|
||||
*
|
||||
* Defaults come from measurement: 20 s is the smallest window that reaches
|
||||
* 0.0% CER (16 s still errs at 5.9%), while inference cost grows
|
||||
* super-linearly — 60 s of audio needs roughly 13x longer to decode than
|
||||
* 20 s does, leaving too little real-time headroom.
|
||||
*/
|
||||
class CwDeepBuffer(
|
||||
sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE,
|
||||
maxSeconds: Double = DEFAULT_MAX_SECONDS,
|
||||
private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_MAX_SECONDS = 20.0
|
||||
const val DEFAULT_REDECODE_INTERVAL_MS = 1500
|
||||
}
|
||||
|
||||
/** Maximum number of samples retained. */
|
||||
val capacity: Int = (sampleRate * maxSeconds).toInt()
|
||||
|
||||
private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000
|
||||
private val ring = FloatArray(capacity)
|
||||
private var writeIndex = 0
|
||||
private var filled = 0
|
||||
private var sinceLastRedecode = 0
|
||||
|
||||
/** Samples currently buffered, never above [capacity]. */
|
||||
val size: Int get() = filled
|
||||
|
||||
/** True once there is enough audio for the spectrogram to yield a frame. */
|
||||
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
|
||||
|
||||
/**
|
||||
* Append captured audio, overwriting the oldest samples when full.
|
||||
*
|
||||
* @return true when [redecodeIntervalMs] of audio has accumulated since
|
||||
* the last time this returned true, meaning the caller should re-decode.
|
||||
*/
|
||||
fun append(chunk: FloatArray): Boolean {
|
||||
if (chunk.isNotEmpty()) {
|
||||
// A chunk longer than the window can only contribute its tail.
|
||||
val start = maxOf(0, chunk.size - capacity)
|
||||
for (i in start until chunk.size) {
|
||||
ring[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
}
|
||||
filled = minOf(capacity, filled + (chunk.size - start))
|
||||
}
|
||||
|
||||
sinceLastRedecode += chunk.size
|
||||
if (sinceLastRedecode >= samplesPerInterval) {
|
||||
sinceLastRedecode -= samplesPerInterval
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Buffered audio in chronological order, as a copy safe to hand off. */
|
||||
fun snapshot(): FloatArray {
|
||||
val out = FloatArray(filled)
|
||||
if (filled == 0) return out
|
||||
val start = (writeIndex - filled + capacity) % capacity
|
||||
val firstRun = minOf(filled, capacity - start)
|
||||
ring.copyInto(out, 0, start, start + firstRun)
|
||||
if (firstRun < filled) {
|
||||
ring.copyInto(out, firstRun, 0, filled - firstRun)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drop all audio and restart the re-decode interval. */
|
||||
fun reset() {
|
||||
writeIndex = 0
|
||||
filled = 0
|
||||
sinceLastRedecode = 0
|
||||
ring.fill(0f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rolling audio buffer feeding DeepCW.
|
||||
*
|
||||
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
|
||||
* rewrites earlier output whenever more context arrives, so incremental
|
||||
* stitching is impossible. Instead we keep a bounded window and re-decode all
|
||||
* of it periodically, replacing the displayed text.
|
||||
*/
|
||||
class CwDeepBufferTest {
|
||||
|
||||
@Test
|
||||
fun capacityIsCappedAtMaxSeconds() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
|
||||
repeat(30) { buffer.append(FloatArray(3200)) }
|
||||
assertEquals(3200 * 20, buffer.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldestSamplesAreDiscardedFirst() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f))
|
||||
buffer.append(floatArrayOf(4f, 5f))
|
||||
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotIsChronologicalAfterWrapAround() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
|
||||
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appendLargerThanCapacityKeepsOnlyTheTail() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
|
||||
assertEquals(4, buffer.size)
|
||||
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redecodeIsSignalledOncePerInterval() {
|
||||
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
|
||||
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redecodeIntervalDoesNotDriftOverManyChunks() {
|
||||
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
|
||||
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
var fired = 0
|
||||
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
|
||||
assertEquals(10, fired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotDoesNotAliasInternalStorage() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.snapshot()[0] = 99f
|
||||
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsSamplesAndIntervalCounter() {
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
buffer.append(FloatArray(3200))
|
||||
buffer.reset()
|
||||
assertEquals(0, buffer.size)
|
||||
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasEnoughAudioTracksTheModelMinimum() {
|
||||
// compute() needs at least FFT_LENGTH samples to produce one frame.
|
||||
val buffer = CwDeepBuffer(3200, 20.0)
|
||||
buffer.append(FloatArray(100))
|
||||
assertFalse(buffer.hasEnoughAudio)
|
||||
buffer.append(FloatArray(200))
|
||||
assertTrue(buffer.hasEnoughAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsMatchTheMeasuredOptimum() {
|
||||
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
|
||||
// reaching 0.0% CER, and keeps inference well inside real time.
|
||||
val buffer = CwDeepBuffer()
|
||||
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user