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