@@ -6,71 +6,71 @@ import kotlin.math.roundToInt
66import kotlin.math.sin
77
88/* *
9- * Encodes arbitrary byte data into MFSK audio PCM samples.
9+ * Encodes ASCII text into standard MFSK-16 audio PCM samples or symbol index sequences .
1010 *
11- * Input bytes are treated as a flat bit stream (MSB-first within each byte). Bits are extracted
12- * [MFSKMode.bitsPerSymbol] at a time to produce a symbol index, which selects a tone frequency.
13- * If the total bit count is not evenly divisible by [MFSKMode.bitsPerSymbol], the final symbol
14- * is zero-padded to complete it.
11+ * Input text is Varicode-encoded per the PSK31 standard before modulation. The resulting
12+ * transmissions are decodable by any compliant MFSK-16 receiver.
1513 *
1614 * ## Tone mapping
1715 * Symbol index S maps to frequency: `baseFrequencyHz + S × mode.toneSpacingHz`
18- * For MFSK-16 this is equivalent to treating each byte as two 4-bit nibbles (high nibble first),
19- * which is a useful sanity check when testing the general implementation.
2016 *
2117 * ## Phase continuity
22- * Phase is tracked as a continuous accumulator across symbol boundaries. Resetting phase to zero
23- * at each boundary would cause spectral splatter at transitions; continuous phase keeps the
24- * output spectrally clean. Phase is wrapped to [0, 2π) once per symbol to prevent
25- * floating-point precision loss over long transmissions.
18+ * Phase is tracked as a continuous accumulator across symbol boundaries. Resetting phase
19+ * to zero at each boundary would cause spectral splatter at transitions; continuous phase
20+ * keeps the output spectrally clean. Phase is wrapped to [0, 2π) once per symbol to
21+ * prevent floating-point precision loss over long transmissions.
22+ *
23+ * ## Input constraint
24+ * Both public functions require pure ASCII input (code points 0–127). Non-ASCII input is a
25+ * programming error and is rejected with [IllegalArgumentException].
2626 */
2727object MFSKEncoder
2828{
29+ // -------------------------------------------------------------------------
30+ // Public API
31+ // -------------------------------------------------------------------------
32+
2933 /* *
30- * Encodes [data ] as an MFSK audio signal.
34+ * Encodes [text ] as a standard MFSK audio signal.
3135 *
32- * @param data Raw bytes to encode (e.g. ciphertext). Must not be empty.
36+ * [text] is Varicode-encoded, then packed into MFSK symbols, then modulated
37+ * as PCM audio with continuous phase.
38+ *
39+ * @param text ASCII text to encode. Must not be empty. Must contain
40+ * only characters with code points in [0, 127].
3341 * @param mode MFSK mode defining tone count, baud rate, and tone spacing.
3442 * @param baseFrequencyHz Frequency of tone index 0, in Hz.
3543 * @param sampleRate Audio pipeline sample rate in Hz (e.g. 12000).
36- * @param amplitude Output level as a fraction of full scale, in [0.0, 1.0]. Default 0.5.
37- * Sine output spans [-1.0, 1.0], so PCM output spans
38- * [-amplitude × 32767, +amplitude × 32767].
39- * Uses Kotlin's ClosedFloatingPointRange — boundary values 0.0 and
40- * 1.0 are valid.
44+ * @param amplitude Output level as a fraction of full scale, in [0.0, 1.0].
45+ * Default 0.5. Sine output spans [-1.0, 1.0], so PCM output
46+ * spans [-amplitude × 32767, +amplitude × 32767].
4147 * @return 16-bit PCM samples representing the encoded signal.
4248 */
4349 fun encode (
44- data : ByteArray ,
50+ text : String ,
4551 mode : MFSKMode ,
4652 baseFrequencyHz : Double ,
4753 sampleRate : Int ,
4854 amplitude : Double = 0.5
4955 ): ShortArray
5056 {
51- require(data.isNotEmpty()) { " data must not be empty" }
52- require(sampleRate > 0 ) { " sampleRate must be positive, was $sampleRate " }
53- require(amplitude in 0.0 .. 1.0 ) { " amplitude must be in [0.0, 1.0], was $amplitude " }
57+ require(text.isNotEmpty()) { " text must not be empty" }
58+ require(text.all { it.code <= 127 }) { " MFSKEncoder input must be ASCII" }
59+ require(sampleRate > 0 ) { " sampleRate must be positive, was $sampleRate " }
60+ require(amplitude in 0.0 .. 1.0 ) { " amplitude must be in [0.0, 1.0], was $amplitude " }
5461
62+ val symbols = extractSymbols(Varicode .encode(text), mode)
5563 val samplesPerSymbol = mode.samplesPerSymbol(sampleRate)
56- val symbols = extractSymbols(data, mode)
57- val symbolCount = symbols.size
58- val output = ShortArray (symbolCount * samplesPerSymbol)
59-
60- // Scale factor for PCM output. Sine is in [-1.0, 1.0], so this bounds
61- // output to [-peakAmplitude, +peakAmplitude] within Short range.
62- val peakAmplitude = amplitude * 32767.0
64+ val output = ShortArray (symbols.size * samplesPerSymbol)
65+ val peakAmplitude = amplitude * 32767.0
6366
6467 // Continuous phase accumulator. Carries over between symbols so that the
6568 // waveform is uninterrupted at tone transitions.
6669 var phase = 0.0
6770
6871 for (symbolIndex in symbols.indices)
6972 {
70- val toneIndex = symbols[symbolIndex]
71-
72- // --- Generate samples for this symbol's tone ---
73- val toneFrequencyHz = baseFrequencyHz + toneIndex * mode.toneSpacingHz
73+ val toneFrequencyHz = baseFrequencyHz + symbols[symbolIndex] * mode.toneSpacingHz
7474 val phaseIncrement = 2.0 * PI * toneFrequencyHz / sampleRate
7575 val sampleOffset = symbolIndex * samplesPerSymbol
7676
@@ -79,52 +79,71 @@ object MFSKEncoder
7979 // roundToInt() rather than toInt(): toInt() truncates toward zero, introducing
8080 // a consistent 0.5 LSB bias on negative samples. roundToInt() distributes
8181 // error symmetrically — at most 0.5 LSB in either direction.
82- output[sampleOffset + sampleIndex] = (peakAmplitude * sin(phase)).roundToInt().toShort()
82+ output[sampleOffset + sampleIndex] =
83+ (peakAmplitude * sin(phase)).roundToInt().toShort()
8384 phase + = phaseIncrement
8485 }
8586
8687 // Wrap phase to [0, 2π) once per symbol. Sin is 2π-periodic so this is lossless.
87- // Per-symbol wrapping (rather than per-sample) is sufficient to prevent precision
88- // loss and avoids the % operation on every sample.
88+ // Per-symbol wrapping prevents precision loss without the cost of % on every sample.
8989 phase % = (2.0 * PI )
9090 }
9191
9292 return output
9393 }
9494
95- fun encodeToSymbols (data : ByteArray , mode : MFSKMode ): IntArray
95+ /* *
96+ * Encodes [text] as a sequence of MFSK symbol indices without generating audio.
97+ *
98+ * [text] is Varicode-encoded, then packed into symbol indices. Intended for hardware
99+ * TX paths where the caller converts each symbol index to a tone frequency
100+ * directly rather than consuming PCM.
101+ *
102+ * Symbol index S corresponds to frequency: `baseFrequencyHz + S × mode.toneSpacingHz`
103+ *
104+ * @param text ASCII text to encode. Must not be empty. Must contain only characters
105+ * with code points in [0, 127].
106+ * @param mode MFSK mode defining tone count, baud rate, and tone spacing.
107+ * @return Symbol indices in transmission order, each in [0, mode.toneCount).
108+ */
109+ fun encodeToSymbols (text : String , mode : MFSKMode ): IntArray
96110 {
97- require(data.isNotEmpty()) { " data must not be empty" }
98- return extractSymbols(data, mode)
111+ require(text.isNotEmpty()) { " text must not be empty" }
112+ require(text.all { it.code <= 127 }) { " MFSKEncoder input must be ASCII" }
113+ return extractSymbols(Varicode .encode(text), mode)
99114 }
100115
101- private fun extractSymbols (data : ByteArray , mode : MFSKMode ): IntArray
116+ // -------------------------------------------------------------------------
117+ // Private helpers
118+ // -------------------------------------------------------------------------
119+
120+ /* *
121+ * Packs a Varicode bit stream into MFSK symbol indices.
122+ *
123+ * Bits are consumed [MFSKMode.bitsPerSymbol] at a time, MSB-first within each symbol.
124+ * If the total bit count is not evenly divisible by [mode.bitsPerSymbol], the final
125+ * symbol is zero-padded. The receiver's Varicode decoder handles this gracefully —
126+ * the padding bits form an incomplete code word that produces no output character.
127+ *
128+ * @param bits Varicode-encoded bit stream from [Varicode.encode].
129+ * @param mode MFSK mode defining bits per symbol.
130+ * @return Symbol indices, one per [mode.bitsPerSymbol] input bits.
131+ */
132+ private fun extractSymbols (bits : BooleanArray , mode : MFSKMode ): IntArray
102133 {
103- val totalBits = data.size * 8
104- val symbolCount = ceil(totalBits.toDouble() / mode.bitsPerSymbol).toInt()
134+ val symbolCount = ceil(bits.size.toDouble() / mode.bitsPerSymbol).toInt()
105135 val symbols = IntArray (symbolCount)
106136
107137 for (symbolIndex in 0 until symbolCount)
108138 {
109139 var toneIndex = 0
110- val startBit = symbolIndex * mode.bitsPerSymbol
111140
112141 for (bitOffset in 0 until mode.bitsPerSymbol)
113142 {
114- val bitPosition = startBit + bitOffset
115-
116- val bit = if (bitPosition < totalBits)
117- {
118- val byteIndex = bitPosition / 8
119- val bitInByte = 7 - (bitPosition % 8 )
120- (data[byteIndex].toInt() ushr bitInByte) and 1
121- }
122- else
123- {
124- 0
125- }
126-
127- toneIndex = (toneIndex shl 1 ) or bit
143+ val bitPosition = symbolIndex * mode.bitsPerSymbol + bitOffset
144+ val bit = if (bitPosition < bits.size) bits[bitPosition] else false
145+
146+ toneIndex = (toneIndex shl 1 ) or (if (bit) 1 else 0 )
128147 }
129148
130149 symbols[symbolIndex] = toneIndex
0 commit comments