1+ package org.operatorfoundation.audiocoder.mfsk
2+
3+ import kotlin.math.ceil
4+
5+ /* *
6+ * Decodes MFSK audio PCM samples back into the original byte data.
7+ *
8+ * This is the inverse of [MFSKEncoder]. For each symbol window, it runs a full Goertzel
9+ * filter bank across all [MFSKMode.toneCount] tone frequencies and selects the highest-energy
10+ * tone as the symbol decision. Symbol indices are then unpacked into a bit stream (MSB-first)
11+ * and assembled into output bytes.
12+ *
13+ * ## Length requirement
14+ * The decoder requires [byteCount] to be specified explicitly. Without it, there is no way to
15+ * distinguish real data bits from the zero-padding the encoder appended to fill out the final
16+ * symbol — a distinction that matters critically with ciphertext, where padding bytes are valid
17+ * byte values indistinguishable by content. The framing layer ([MFSKStation]) is responsible
18+ * for communicating byte count, typically via a length prefix prepended before encoding.
19+ *
20+ * ## Signal quality
21+ * The decoder always picks a winner — the tone with the highest Goertzel energy — with no
22+ * minimum threshold. A symbol window of pure noise will still yield a decoded symbol; it will
23+ * simply be wrong. Corruption will propagate to the output bytes and should be caught by the
24+ * authentication layer above (e.g. the encryption layer in Nahoft). Signal quality gating
25+ * belongs in [MFSKStation], which has the context to make that judgment before calling decode.
26+ */
27+ object MFSKDecoder
28+ {
29+ /* *
30+ * Decodes MFSK audio samples into the original byte data.
31+ *
32+ * @param samples PCM audio containing the MFSK signal. Must contain at least
33+ * enough samples for [byteCount] bytes at the given [mode] and
34+ * [sampleRate] — see [MFSKMode.samplesPerSymbol].
35+ * @param mode MFSK mode used to encode the signal. Must match the encoder's mode.
36+ * @param baseFrequencyHz Frequency of tone index 0 in Hz. Must match the encoder's value.
37+ * @param sampleRate Audio pipeline sample rate in Hz (e.g. 12000).
38+ * @param byteCount Number of bytes to decode. Must match the original plaintext length.
39+ * @return Decoded bytes. Length is exactly [byteCount].
40+ */
41+ fun decode (
42+ samples : ShortArray ,
43+ mode : MFSKMode ,
44+ baseFrequencyHz : Double ,
45+ sampleRate : Int ,
46+ byteCount : Int
47+ ): ByteArray
48+ {
49+ require(byteCount > 0 ) { " byteCount must be positive, was $byteCount " }
50+ require(sampleRate > 0 ) { " sampleRate must be positive, was $sampleRate " }
51+
52+ val samplesPerSymbol = mode.samplesPerSymbol(sampleRate)
53+ val totalBits = byteCount * 8
54+
55+ // Round up: mirrors the encoder's ceil() so symbol count is always consistent.
56+ val symbolCount = ceil(totalBits.toDouble() / mode.bitsPerSymbol).toInt()
57+ val expectedSampleCount = symbolCount * samplesPerSymbol
58+
59+ // Reject inputs that are too short to contain the expected data. Silent truncation
60+ // would produce garbage output with no indication of failure — unacceptable for
61+ // ciphertext where partial output is indistinguishable from valid output.
62+ // The require also guarantees the last window's copyOfRange upper bound
63+ // (symbolCount * samplesPerSymbol == expectedSampleCount) never exceeds samples.size.
64+ require(samples.size >= expectedSampleCount) {
65+ " samples too short: need $expectedSampleCount for $byteCount bytes in ${mode.label} , " +
66+ " got ${samples.size} "
67+ }
68+
69+ // Precompute tone frequencies once — constant across all symbol windows.
70+ val toneFrequencies = DoubleArray (mode.toneCount) { toneIndex ->
71+ baseFrequencyHz + toneIndex * mode.toneSpacingHz
72+ }
73+
74+ val output = ByteArray (byteCount)
75+ var bitPosition = 0
76+
77+ for (symbolIndex in 0 until symbolCount)
78+ {
79+ // Slice exactly one symbol window from the sample buffer.
80+ // copyOfRange allocates a new array; at 15.625 symbols/sec this is negligible.
81+ // If profiling ever shows this as a bottleneck, GoertzelFilter could be extended
82+ // to accept an offset+length into the full buffer instead.
83+ val windowStart = symbolIndex * samplesPerSymbol
84+ val window = samples.copyOfRange(windowStart, windowStart + samplesPerSymbol)
85+
86+ // --- Run the Goertzel filter bank ---
87+ val energies = DoubleArray (mode.toneCount) { toneIndex ->
88+ GoertzelFilter .energy(window, toneFrequencies[toneIndex], sampleRate)
89+ }
90+
91+ // Always pick the highest-energy tone as the symbol decision.
92+ // !! is safe: maxByOrNull returns null only for empty collections, and
93+ // toneCount is always >= 8 by MFSKMode's design.
94+ val winnerToneIndex = energies.indices.maxByOrNull { energies[it] }!!
95+
96+ // --- Unpack bitsPerSymbol bits from the winner, MSB-first ---
97+ // The encoder built toneIndex by shifting input bits in MSB-first, so reversing
98+ // means extracting from the most significant bit of winnerToneIndex downward.
99+ for (bitOffset in 0 until mode.bitsPerSymbol)
100+ {
101+ // Stop exactly at the real data boundary — don't decode zero-padding bits.
102+ if (bitPosition >= totalBits) break
103+
104+ val bitInSymbol = mode.bitsPerSymbol - 1 - bitOffset
105+ val bit = (winnerToneIndex ushr bitInSymbol) and 1
106+
107+ // Place bit into the output byte at its MSB-first position.
108+ val byteIndex = bitPosition / 8
109+ val bitInByte = 7 - (bitPosition % 8 )
110+
111+ if (bit == 1 )
112+ {
113+ output[byteIndex] = (output[byteIndex].toInt() or (1 shl bitInByte)).toByte()
114+ }
115+ // bit == 0: output bytes are zero-initialised by ByteArray constructor, no action needed.
116+
117+ bitPosition++
118+ }
119+ }
120+
121+ return output
122+ }
123+ }
0 commit comments