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:
mckero committed 2026-08-12 12:13:55 +00:00
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)
}
}