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)
+ }
+}