feat(cw): archive decoded history, polish the waterfall, document licensing
三个用户反馈一并解决: 1. 解码文字不再消失 (核心) 旧实现: 20 秒环形缓冲满了就静默覆盖最旧样本, 文字随之从屏幕消失。 新实现: CwDeepBuffer 新增 overflow —— 满时被覆盖的旧样本先进 overflow, 解码器累积到 15 秒就单独解码一次, 结果追加到 historyText (只增不减)。 UI 记录区显示 historyText + decodedText (历史稳定 + 当前窗口实时)。 ICwDecoder 接口新增 historyText StateFlow。 归档音频已离开主窗口, 不再被 CTC 修正, 故其文本是"最终版", 追加安全。 归档窗口 15 秒: 内容已在 20 秒窗口里解过多次, 短一点几乎无损, 且推理 开销小。 2. 瀑布图更好看 配色从"深蓝->青->黄"换成 matplotlib inferno (黑->紫->品红->橙->黄), 与静态频谱图保持一致。相邻 bin 之间用水平渐变做线性插值, 消除 65 列 离散方块的像素感。 3. AGPL 合规补漏 (用户提醒: 仓库许可证没体现 AGPL 组件) README 新增 License 章节: 声明项目主体 GPL-3.0 + feature/cw 的 DeepCW 模型 AGPL-3.0-only, 并说明合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理。 验证: - :core:domain:test => 31 个 CW 测试全绿 (CwDeepBufferTest 新增 3 个 overflow 归档测试: 顺序/清空/reset) - :core:domain:compileKotlin + :core:data + :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
This commit is contained in:
1 parent
f42d1f6c57
commit
2e1d8b9c00
7 files changed
+192
-23
No files matched your search
@@ -54,9 +54,22 @@ class CwDeepBuffer(
|
||||
private var filled = 0
|
||||
private var sinceLastRedecode = 0
|
||||
|
||||
/**
|
||||
* Samples evicted from the ring once it is full. They are the audio that
|
||||
* has scrolled out of the 20 s window, and are handed off (via
|
||||
* [drainOverflow]) so the decoder can archive them into permanent history
|
||||
* instead of silently dropping the corresponding text. Pre-allocated to
|
||||
* [capacity]: overflow never exceeds one window before it is drained.
|
||||
*/
|
||||
private val overflow = FloatArray(capacity)
|
||||
private var overflowSize = 0
|
||||
|
||||
/** Samples currently buffered, never above [capacity]. */
|
||||
val size: Int get() = filled
|
||||
|
||||
/** Samples currently held in the overflow (awaiting archival). */
|
||||
val overflowCount: Int get() = overflowSize
|
||||
|
||||
/** True once there is enough audio for the spectrogram to yield a frame. */
|
||||
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
|
||||
|
||||
@@ -71,10 +84,15 @@ class CwDeepBuffer(
|
||||
// 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) {
|
||||
if (filled == capacity) {
|
||||
// The slot we are about to overwrite holds the oldest
|
||||
// sample — move it to the overflow for archival.
|
||||
overflow[overflowSize++] = ring[writeIndex]
|
||||
}
|
||||
ring[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
if (filled < capacity) filled++
|
||||
}
|
||||
filled = minOf(capacity, filled + (chunk.size - start))
|
||||
}
|
||||
|
||||
sinceLastRedecode += chunk.size
|
||||
@@ -98,11 +116,25 @@ class CwDeepBuffer(
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drop all audio and restart the re-decode interval. */
|
||||
/**
|
||||
* Return the evicted samples (chronological order) and clear the overflow.
|
||||
* Safe to call every append; returns an empty array when nothing has been
|
||||
* evicted yet.
|
||||
*/
|
||||
fun drainOverflow(): FloatArray {
|
||||
if (overflowSize == 0) return FloatArray(0)
|
||||
val out = overflow.copyOf(overflowSize)
|
||||
overflowSize = 0
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drop all audio (including pending overflow) and restart the interval. */
|
||||
fun reset() {
|
||||
writeIndex = 0
|
||||
filled = 0
|
||||
sinceLastRedecode = 0
|
||||
overflowSize = 0
|
||||
ring.fill(0f)
|
||||
overflow.fill(0f)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface ICwDecoder {
|
||||
|
||||
/**
|
||||
* Decoded text for display.
|
||||
* Decoded text for the *current* window.
|
||||
*
|
||||
* Note this is **replace** semantics, not append: a whole-segment model
|
||||
* revises earlier characters as more audio arrives, so consumers must show
|
||||
@@ -36,6 +36,13 @@ interface ICwDecoder {
|
||||
*/
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Permanent transcript of everything that has scrolled out of the live
|
||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||
* what the user reads back after a signal has passed.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
/** Detected tone frequency in Hz, or null before a tone is found. */
|
||||
val estimatedPitch: StateFlow<Float?>
|
||||
|
||||
|
||||
@@ -118,4 +118,35 @@ class CwDeepBufferTest {
|
||||
val buffer = CwDeepBuffer()
|
||||
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overflowCollectsEvictedSamplesInOrder() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
|
||||
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
|
||||
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
|
||||
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drainOverflowClearsItself() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.append(floatArrayOf(5f))
|
||||
assertEquals(1, buffer.overflowCount)
|
||||
buffer.drainOverflow()
|
||||
assertEquals(0, buffer.overflowCount)
|
||||
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsOverflow() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.append(floatArrayOf(5f))
|
||||
assertEquals(1, buffer.overflowCount)
|
||||
buffer.reset()
|
||||
assertEquals(0, buffer.overflowCount)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user