Skip to content

Commit c11d3e7

Browse files
committed
Added MFSK-16 support to AudioCoder
1 parent 0c16edc commit c11d3e7

15 files changed

Lines changed: 1347 additions & 96 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package org.operatorfoundation.audiocoder.common.models
2+
3+
/**
4+
* Status and diagnostic information for an audio source.
5+
*
6+
* This is a general-purpose status type shared across AudioCoder's audio source interfaces
7+
* ([org.operatorfoundation.audiocoder.wspr.WSPRAudioSource],
8+
* [org.operatorfoundation.audiocoder.mfsk.MFSKAudioSource], etc.).
9+
* Protocol-specific compatibility checks (e.g. WSPR sample rate requirements) are
10+
* implemented as extension functions in the relevant protocol package rather than here.
11+
*/
12+
data class AudioSourceStatus(
13+
/** Whether the audio source is currently operational. */
14+
val isOperational: Boolean,
15+
16+
/** Current audio sample rate in Hz. */
17+
val currentSampleRateHz: Int,
18+
19+
/** Number of audio channels. */
20+
val channelCount: Int,
21+
22+
/** Audio bit depth. */
23+
val bitDepth: Int,
24+
25+
/** Human-readable description of current source state. */
26+
val statusDescription: String,
27+
28+
/** Optional error message if source is not operational. */
29+
val errorMessage: String? = null,
30+
31+
/** Timestamp when status was last updated (milliseconds since epoch). */
32+
val lastUpdated: Long = System.currentTimeMillis()
33+
)
34+
{
35+
companion object
36+
{
37+
/**
38+
* Creates a status indicating the source is not operational.
39+
*
40+
* @param errorDescription Reason why the source is not working.
41+
*/
42+
fun createNonOperationalStatus(errorDescription: String): AudioSourceStatus =
43+
AudioSourceStatus(
44+
isOperational = false,
45+
currentSampleRateHz = 0,
46+
channelCount = 0,
47+
bitDepth = 0,
48+
statusDescription = "Not operational",
49+
errorMessage = errorDescription
50+
)
51+
52+
/**
53+
* Creates a status indicating the source is working correctly.
54+
*
55+
* @param sampleRateHz Sample rate the source is currently providing.
56+
* @param channelCount Channel count the source is currently providing.
57+
* @param bitDepth Bit depth the source is currently providing.
58+
* @param description Optional description of current operation.
59+
*/
60+
fun createOperationalStatus(
61+
sampleRateHz: Int,
62+
channelCount: Int,
63+
bitDepth: Int,
64+
description: String = "Operating normally"
65+
): AudioSourceStatus =
66+
AudioSourceStatus(
67+
isOperational = true,
68+
currentSampleRateHz = sampleRateHz,
69+
channelCount = channelCount,
70+
bitDepth = bitDepth,
71+
statusDescription = description
72+
)
73+
}
74+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package org.operatorfoundation.audiocoder.mfsk
2+
3+
import kotlin.math.cos
4+
import kotlin.math.PI
5+
6+
/**
7+
* Stateless implementation of the Goertzel algorithm for single-tone energy detection.
8+
*
9+
* The Goertzel algorithm is an efficient DFT evaluation for a single frequency bin. It is
10+
* the standard choice for MFSK decoding because it computes energy at exactly N target
11+
* frequencies (one per tone) rather than evaluating the full spectrum — far cheaper than
12+
* an FFT when N is small.
13+
*
14+
* ## Usage in MFSK decoding
15+
* The decoder calls [energy] once per tone per symbol window. It then selects the tone index
16+
* with the highest returned energy as the symbol decision. All 16 calls share the same
17+
* [samples] and [sampleRate]; only [targetFrequencyHz] varies.
18+
*
19+
* ## Formulation
20+
* This implementation uses the single-pass optimized formulation, which avoids a second
21+
* pass over the sample buffer and is equivalent to the standard two-pass version:
22+
*
23+
* ```
24+
* coefficient = 2 × cos(2π × f / sampleRate)
25+
* s_prev2 = 0, s_prev1 = 0
26+
* for each sample x:
27+
* s = x + coefficient × s_prev1 − s_prev2
28+
* s_prev2 = s_prev1
29+
* s_prev1 = s
30+
* energy = s_prev2² + s_prev1² − coefficient × s_prev1 × s_prev2
31+
* ```
32+
*/
33+
object GoertzelFilter
34+
{
35+
/**
36+
* Normalization divisor for 16-bit PCM samples.
37+
* Dividing a raw sample value by this constant maps the range [-32768, 32767] to [-1.0, 1.0].
38+
* Symbol decisions are correct without normalization (the relative energies are the same),
39+
* but normalized input keeps energy values in a meaningful range for any future
40+
* absolute-level thresholding (e.g. squelch or signal quality estimation).
41+
*/
42+
private const val PCM_NORMALIZATION_FACTOR = 32768.0
43+
44+
/**
45+
* Evaluates the energy at [targetFrequencyHz] over the given sample window.
46+
*
47+
* The input [samples] should contain exactly one symbol period worth of audio
48+
* (i.e. [MFSKMode.samplesPerSymbol] samples). Passing a shorter or longer window
49+
* is not an error but will affect energy accuracy — the Goertzel filter is
50+
* tuned for a window size equal to [samples.size].
51+
*
52+
* Returned energy is raw (not normalized by sample count). All tones in a symbol
53+
* decision are evaluated over the same window, so raw values are directly comparable.
54+
*
55+
* @param samples One symbol window of 16-bit PCM audio.
56+
* @param targetFrequencyHz The tone frequency to measure, in Hz.
57+
* @param sampleRate Audio pipeline sample rate in Hz (e.g. 12000).
58+
* @return Raw Goertzel energy at the target frequency. Higher values indicate
59+
* stronger presence of that tone in the sample window.
60+
*/
61+
fun energy(
62+
samples: ShortArray,
63+
targetFrequencyHz: Double,
64+
sampleRate: Int
65+
): Double
66+
{
67+
// Precompute the Goertzel coefficient for this frequency and sample rate.
68+
// This would be worth caching if called in a tight inner loop with fixed parameters,
69+
// but at 16 tones per symbol the recalculation cost is negligible.
70+
val coefficient = 2.0 * cos(2.0 * PI * targetFrequencyHz / sampleRate)
71+
72+
var sPrev2 = 0.0
73+
var sPrev1 = 0.0
74+
75+
for (sample in samples)
76+
{
77+
// Normalize PCM sample from [-32768, 32767] to [-1.0, 1.0] before filtering.
78+
val normalizedSample = sample / PCM_NORMALIZATION_FACTOR
79+
80+
val s = normalizedSample + coefficient * sPrev1 - sPrev2
81+
sPrev2 = sPrev1
82+
sPrev1 = s
83+
}
84+
85+
// Single-pass energy formula: equivalent to |X(k)|² from the DFT.
86+
return sPrev2 * sPrev2 + sPrev1 * sPrev1 - coefficient * sPrev1 * sPrev2
87+
}
88+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.operatorfoundation.audiocoder.mfsk
2+
3+
import org.operatorfoundation.audiocoder.common.models.AudioSourceStatus
4+
5+
/**
6+
* Interface for providing audio data to an MFSK station.
7+
*
8+
* Mirrors [org.operatorfoundation.audiocoder.wspr.WSPRAudioSource] in structure.
9+
* Audio must be provided as 16-bit signed PCM, mono. Sample rate is specified
10+
* in [MFSKConfiguration] rather than fixed here — 12kHz is the recommended rate,
11+
* as it divides cleanly into all standard MFSK baud rates with no rounding error.
12+
*
13+
* If implementations need to signal unrecoverable errors with a typed exception,
14+
* use or extend [MFSKAudioSourceException] in this package rather than importing
15+
* WSPR-specific exception types.
16+
*/
17+
interface MFSKAudioSource
18+
{
19+
/**
20+
* Initializes the audio source and prepares it for audio delivery.
21+
* The method should be idempotent — calling it multiple times must not cause
22+
* errors or resource leaks.
23+
*
24+
* @return Success if initialization completed without errors,
25+
* Failure with descriptive error information otherwise.
26+
*/
27+
suspend fun initialize(): Result<Unit>
28+
29+
/**
30+
* Reads a chunk of audio data covering the specified time duration.
31+
*
32+
* @param durationMs Requested audio duration in milliseconds.
33+
* @return Array of 16-bit audio samples. May be shorter than requested
34+
* if insufficient audio is available.
35+
*/
36+
suspend fun readAudioChunk(durationMs: Long): ShortArray
37+
38+
/**
39+
* Releases all resources and stops audio acquisition.
40+
* Safe to call multiple times. Must not throw.
41+
*/
42+
suspend fun cleanup()
43+
44+
/**
45+
* Discards all buffered audio samples. Call immediately before beginning
46+
* a decode window to ensure only time-aligned audio reaches the decoder.
47+
* Default implementation is a no-op for sources that do not buffer.
48+
*/
49+
suspend fun flushBuffer() {}
50+
51+
/**
52+
* Returns current status and diagnostic information about this audio source.
53+
*/
54+
suspend fun getSourceStatus(): AudioSourceStatus
55+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.operatorfoundation.audiocoder.mfsk
2+
3+
/**
4+
* Configuration for an [MFSKStation].
5+
*
6+
* @param mode MFSK modulation mode (tone count, baud rate, spacing).
7+
* @param baseFrequencyHz Frequency of tone index 0 in Hz. All other tones are placed at
8+
* integer multiples of [MFSKMode.toneSpacingHz] above this value.
9+
* @param sampleRate Audio pipeline sample rate in Hz. 12000 Hz is strongly recommended —
10+
* it divides cleanly into all standard MFSK baud rates with no rounding
11+
* error, and matches the rate SignalBridge already delivers for WSPR.
12+
* @param amplitude Transmit output level as a fraction of full scale, in [0.0, 1.0].
13+
* @param timeoutMs Maximum time in milliseconds to wait for a complete message before
14+
* abandoning the current receive attempt. At MFSK-16's ~1.95 bytes/second,
15+
* a standard 40-byte Nahoft encrypted message transmits in ~21 seconds.
16+
* The default of 60 000 ms provides ~2.5× margin for timing jitter and
17+
* marginal signal conditions.
18+
*/
19+
data class MFSKConfiguration(
20+
val mode: MFSKMode,
21+
val baseFrequencyHz: Double,
22+
val sampleRate: Int = 12_000,
23+
val amplitude: Double = 0.5,
24+
val timeoutMs: Long = 60_000L
25+
)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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

Comments
 (0)