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:
mckero committed 2026-08-13 08:04:05 +00:00
1 parent f42d1f6c57
commit 2e1d8b9c00
7 files changed
+192 -23

No files matched your search

@@ -94,6 +94,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
var isListening by remember { mutableStateOf(false) }
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val inferenceMs by decoder.lastInferenceMs.collectAsState()
@@ -207,7 +208,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = decodedText,
text = (historyText + decodedText).ifEmpty { "…" },
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import kotlinx.coroutines.flow.MutableStateFlow
@@ -96,7 +97,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
/**
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp goes dark blue -> cyan -> yellow with magnitude.
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
*/
@Composable
internal fun CwWaterfallView(
@@ -126,11 +127,14 @@ internal fun CwWaterfallView(
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
for (bin in row.indices) {
val magnitude = (row[bin] / peak).coerceIn(0f, 1f)
if (magnitude < 0.06f) continue
// Linear interpolation between adjacent bins via a horizontal
// gradient removes the blocky "pixel" look of 65 discrete columns.
for (bin in 0 until row.size - 1) {
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
if (m0 < 0.06f && m1 < 0.06f) continue
drawRect(
color = rampColor(magnitude),
brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))),
topLeft = Offset(bin * binWidth, y),
size = Size(binWidth + 1f, rowHeight + 1f)
)
@@ -147,13 +151,32 @@ internal fun CwWaterfallView(
}
}
private fun rampColor(magnitude: Float): Color = when {
magnitude < 0.5f -> {
val t = magnitude / 0.5f
Color(red = 0f, green = 0.35f * t, blue = 0.35f + 0.55f * t)
}
else -> {
val t = (magnitude - 0.5f) / 0.5f
Color(red = t, green = 0.35f + 0.6f * t, blue = 0.9f - 0.8f * t)
/**
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
* used for the static spectrogram illustration, kept for visual consistency.
*/
private val INFERNO_STOPS = arrayOf(
floatArrayOf(0.00f, 0.000f, 0.000f, 0.016f), // black
floatArrayOf(0.25f, 0.231f, 0.059f, 0.439f), // deep purple
floatArrayOf(0.50f, 0.549f, 0.161f, 0.506f), // magenta
floatArrayOf(0.75f, 0.871f, 0.286f, 0.408f), // red-orange
floatArrayOf(1.00f, 0.988f, 1.000f, 0.643f) // pale yellow
)
private fun inferno(t: Float): Color {
val x = t.coerceIn(0f, 1f)
for (i in 0 until INFERNO_STOPS.size - 1) {
val a = INFERNO_STOPS[i]
val b = INFERNO_STOPS[i + 1]
if (x <= b[0]) {
val f = (x - a[0]) / (b[0] - a[0])
return Color(
red = a[1] + (b[1] - a[1]) * f,
green = a[2] + (b[2] - a[2]) * f,
blue = a[3] + (b[3] - a[3]) * f
)
}
}
return Color(0.988f, 1.0f, 0.643f)
}