diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt new file mode 100644 index 00000000..5b4a4344 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt @@ -0,0 +1,108 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.cw + +/** + * Bounded rolling audio buffer that drives periodic full re-decodes. + * + * DeepCW is a whole-segment CTC model. It rewrites earlier output as more + * context arrives — measured on one clip, 6 of 11 progressively longer reads + * revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only + * the newest fragment therefore leaves those intermediate guesses on screen + * forever; measured character error rate for sliding-window stitching ranged + * from 67% to 294%, against 0% for decoding the whole segment at once. + * + * So we keep a fixed window, re-run the model over all of it every + * [redecodeIntervalMs], and replace the displayed text outright. + * + * Defaults come from measurement: 20 s is the smallest window that reaches + * 0.0% CER (16 s still errs at 5.9%), while inference cost grows + * super-linearly — 60 s of audio needs roughly 13x longer to decode than + * 20 s does, leaving too little real-time headroom. + */ +class CwDeepBuffer( + sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE, + maxSeconds: Double = DEFAULT_MAX_SECONDS, + private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS +) { + companion object { + const val DEFAULT_MAX_SECONDS = 20.0 + const val DEFAULT_REDECODE_INTERVAL_MS = 1500 + } + + /** Maximum number of samples retained. */ + val capacity: Int = (sampleRate * maxSeconds).toInt() + + private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000 + private val ring = FloatArray(capacity) + private var writeIndex = 0 + private var filled = 0 + private var sinceLastRedecode = 0 + + /** Samples currently buffered, never above [capacity]. */ + val size: Int get() = filled + + /** True once there is enough audio for the spectrogram to yield a frame. */ + val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH + + /** + * Append captured audio, overwriting the oldest samples when full. + * + * @return true when [redecodeIntervalMs] of audio has accumulated since + * the last time this returned true, meaning the caller should re-decode. + */ + fun append(chunk: FloatArray): Boolean { + if (chunk.isNotEmpty()) { + // 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) { + ring[writeIndex] = chunk[i] + writeIndex = (writeIndex + 1) % capacity + } + filled = minOf(capacity, filled + (chunk.size - start)) + } + + sinceLastRedecode += chunk.size + if (sinceLastRedecode >= samplesPerInterval) { + sinceLastRedecode -= samplesPerInterval + return true + } + return false + } + + /** Buffered audio in chronological order, as a copy safe to hand off. */ + fun snapshot(): FloatArray { + val out = FloatArray(filled) + if (filled == 0) return out + val start = (writeIndex - filled + capacity) % capacity + val firstRun = minOf(filled, capacity - start) + ring.copyInto(out, 0, start, start + firstRun) + if (firstRun < filled) { + ring.copyInto(out, firstRun, 0, filled - firstRun) + } + return out + } + + /** Drop all audio and restart the re-decode interval. */ + fun reset() { + writeIndex = 0 + filled = 0 + sinceLastRedecode = 0 + ring.fill(0f) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt new file mode 100644 index 00000000..7cb76591 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt @@ -0,0 +1,121 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.cw + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rolling audio buffer feeding DeepCW. + * + * DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it + * rewrites earlier output whenever more context arrives, so incremental + * stitching is impossible. Instead we keep a bounded window and re-decode all + * of it periodically, replacing the displayed text. + */ +class CwDeepBufferTest { + + @Test + fun capacityIsCappedAtMaxSeconds() { + val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0) + repeat(30) { buffer.append(FloatArray(3200)) } + assertEquals(3200 * 20, buffer.size) + } + + @Test + fun oldestSamplesAreDiscardedFirst() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f)) + buffer.append(floatArrayOf(4f, 5f)) + assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f) + } + + @Test + fun snapshotIsChronologicalAfterWrapAround() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f)) + assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f) + } + + @Test + fun appendLargerThanCapacityKeepsOnlyTheTail() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)) + assertEquals(4, buffer.size) + assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f) + } + + @Test + fun redecodeIsSignalledOncePerInterval() { + // 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s. + val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500) + assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200))) + assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600))) + assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600))) + assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600))) + assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600))) + } + + @Test + fun redecodeIntervalDoesNotDriftOverManyChunks() { + // 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per + // 1.5 s interval, so 150 chunks must fire exactly 10 times. + val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500) + var fired = 0 + repeat(150) { if (buffer.append(FloatArray(320))) fired++ } + assertEquals(10, fired) + } + + @Test + fun snapshotDoesNotAliasInternalStorage() { + val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) + buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) + buffer.snapshot()[0] = 99f + assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f) + } + + @Test + fun resetClearsSamplesAndIntervalCounter() { + val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500) + buffer.append(FloatArray(3200)) + buffer.reset() + assertEquals(0, buffer.size) + assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200))) + } + + @Test + fun hasEnoughAudioTracksTheModelMinimum() { + // compute() needs at least FFT_LENGTH samples to produce one frame. + val buffer = CwDeepBuffer(3200, 20.0) + buffer.append(FloatArray(100)) + assertFalse(buffer.hasEnoughAudio) + buffer.append(FloatArray(200)) + assertTrue(buffer.hasEnoughAudio) + } + + @Test + fun defaultsMatchTheMeasuredOptimum() { + // 20s / 1.5s were chosen from measurements: 20s is the smallest window + // reaching 0.0% CER, and keeps inference well inside real time. + val buffer = CwDeepBuffer() + assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity) + } +}