diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt
new file mode 100644
index 00000000..83d552f3
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt
@@ -0,0 +1,61 @@
+/*
+ * 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
+
+/**
+ * Turns the model's `log_probs` output into text.
+ *
+ * Mirrors the reference `greedy_ctc_decode`: take the best class per frame,
+ * drop blanks, and collapse runs of the same label. A blank between two
+ * identical labels is what keeps a genuine double letter (for example the
+ * two N's in "5NN") from collapsing into one.
+ */
+object CwCtcDecoder {
+
+ /**
+ * @param logProbs `[batch, time, class]`; only batch 0 is read.
+ * @param chars class index to symbol, excluding the blank.
+ * @param blankIndex the CTC blank class (41 for this model).
+ */
+ fun greedy(
+ logProbs: Array>,
+ chars: List,
+ blankIndex: Int
+ ): String {
+ if (logProbs.isEmpty()) return ""
+ val frames = logProbs[0]
+ val builder = StringBuilder()
+ var previous = -1
+
+ for (frame in frames) {
+ var best = 0
+ for (i in 1 until frame.size) {
+ if (frame[i] > frame[best]) best = i
+ }
+ if (best == blankIndex) {
+ previous = -1
+ continue
+ }
+ if (best != previous && best < chars.size) {
+ builder.append(chars[best])
+ }
+ previous = best
+ }
+ return builder.toString()
+ }
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt
new file mode 100644
index 00000000..ed1386bb
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.assertEquals
+import org.junit.Test
+
+/**
+ * Greedy CTC collapse, matching the reference implementation's
+ * `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
+ *
+ * Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
+ */
+class CwCtcDecoderTest {
+
+ private val chars = listOf(
+ ",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
+ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
+ "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
+ )
+ private val blank = 41
+
+ /** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
+ private fun logits(path: IntArray): Array> {
+ val frames = Array(path.size) { t ->
+ FloatArray(42) { -10f }.also { it[path[t]] = 0f }
+ }
+ return arrayOf(frames)
+ }
+
+ @Test
+ fun alphabetSizeMatchesModelMetadata() {
+ assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
+ }
+
+ @Test
+ fun greedy_dropsRunsOfTheSameLabel() {
+ assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
+ }
+
+ @Test
+ fun greedy_keepsRepeatsSeparatedByBlank() {
+ // A A A collapses to "AA": the blank breaks the run.
+ assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
+ }
+
+ @Test
+ fun greedy_allBlanksYieldEmptyString() {
+ assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
+ }
+
+ @Test
+ fun greedy_emptyInputYieldsEmptyString() {
+ assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
+ }
+
+ @Test
+ fun greedy_decodesCallsignWithSpaceAndDigits() {
+ // "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
+ val path = intArrayOf(
+ blank, 16, 16, blank, 30, blank, 40,
+ 15, blank, 20, blank, 10, blank
+ )
+ assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
+ }
+
+ @Test
+ fun greedy_picksHighestScoringClassPerFrame() {
+ // Frame favours S (32) over T (33); only S must survive.
+ val frame = FloatArray(42) { -10f }
+ frame[33] = -1f
+ frame[32] = -0.1f
+ assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
+ }
+}