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

+15
View File
@@ -34,6 +34,21 @@ It is now and always will be completely ad-free and open-source.
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## License
Look4Sat is free software licensed under the [GNU General Public License v3.0](LICENSE).
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
neural decoding model, which is licensed under the
[GNU Affero General Public License v3.0](feature/cw/licenses/DeepCW-AGPL-3.0.txt)
(AGPL-3.0-only). Provenance, attribution and the applied int8 quantization are
documented in [`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md).
Because the combined work incorporates an AGPL-3.0 component, the requirements of
AGPL-3.0 Section 13 apply to the combined work as a whole when it is distributed
(GPL-3.0 Section 13 permits this combination). The CW model runs locally on-device
and does not provide services over a network.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
@@ -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
@@ -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)
}
}
@@ -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)
}