Skip to content

Commit 5e258ae

Browse files
committed
replaced custom MFSK framing with standard Varicode/<base64> encoding for MFSK
1 parent 1851165 commit 5e258ae

6 files changed

Lines changed: 601 additions & 501 deletions

File tree

AudioCoder/src/main/java/org/operatorfoundation/audiocoder/mfsk/MFSKDecoder.kt

Lines changed: 0 additions & 117 deletions
This file was deleted.

AudioCoder/src/main/java/org/operatorfoundation/audiocoder/mfsk/MFSKEncoder.kt

Lines changed: 76 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,71 +6,71 @@ import kotlin.math.roundToInt
66
import 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
*/
2727
object 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

AudioCoder/src/main/java/org/operatorfoundation/audiocoder/mfsk/MFSKMode.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ sealed class MFSKMode(
157157
* @param sampleRate Audio pipeline sample rate in Hz (e.g. 12000).
158158
* @return Sample count per symbol at the given rate.
159159
*/
160-
fun samplesPerSymbol(sampleRate: Int): Int = (sampleRate / baudRate).roundToInt()
160+
fun samplesPerSymbol(sampleRate: Int): Int = (sampleRate / baudRate).toInt()
161161

162162
// -------------------------------------------------------------------------
163163
// Standard overrides

0 commit comments

Comments
 (0)