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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user