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
@@ -55,11 +55,18 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
}
|
||||
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
@@ -74,6 +81,16 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
||||
* keeps the extra inference cheap (short window) while long enough to be
|
||||
* decoded accurately — the content has already been through the 20 s window
|
||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
@@ -157,6 +174,21 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val shouldRedecode = buffer.append(resampled)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
// when a full archive chunk has accumulated, so old text does not vanish.
|
||||
val overflow = buffer.drainOverflow()
|
||||
if (overflow.isNotEmpty()) {
|
||||
for (v in overflow) {
|
||||
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
|
||||
}
|
||||
if (archiveSize >= ARCHIVE_THRESHOLD) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
archiveDecode(audio)
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRedecode || !buffer.hasEnoughAudio) return
|
||||
|
||||
// Drop this cycle rather than queue when the previous run is still going.
|
||||
@@ -186,6 +218,37 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
CwProbe.step("infer_begin frames=${window.size}")
|
||||
|
||||
val spectrogram = CwDeepSpectrogram.compute(window)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
|
||||
// Replace, never append: the model rewrites earlier characters as more
|
||||
// context arrives, so appending would leave stale guesses on screen.
|
||||
_decodedText.value = text
|
||||
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
if (text.isNotEmpty()) {
|
||||
_historyText.value += text
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
private fun runInference(
|
||||
activeSession: OrtSession,
|
||||
activeEnvironment: OrtEnvironment,
|
||||
spectrogram: Array<FloatArray>
|
||||
): String {
|
||||
val frames = spectrogram.size
|
||||
val bins = CwDeepSpectrogram.FREQUENCY_BINS
|
||||
|
||||
@@ -205,12 +268,7 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
}
|
||||
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
|
||||
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
|
||||
|
||||
// Replace, never append: the model rewrites earlier characters as more
|
||||
// context arrives, so appending would leave stale guesses on screen.
|
||||
_decodedText.value = text
|
||||
|
||||
updateSignalMetrics(spectrogram)
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +306,8 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
override fun reset() {
|
||||
buffer.reset()
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_signalStrength.value = 0f
|
||||
_lastInferenceMs.value = 0
|
||||
|
||||
Reference in new issue
Block a user