@@ -4,6 +4,13 @@ import org.operatorfoundation.audiocoder.WSPRBandplan.getDefaultFrequency
44import org.operatorfoundation.audiocoder.WSPRConstants.SAMPLE_RATE_HZ
55import org.operatorfoundation.audiocoder.WSPRConstants.SYMBOLS_PER_MESSAGE
66
7+ /* *
8+ * High-level WSPR audio processing with buffering and multiple decode strategies.
9+ *
10+ * This processor manages audio buffering and provides two decoding options:
11+ * 1. Sliding Window: Overlapping windows to catch transmissions at any time
12+ * 2. Time-Aligned: Windows aligned with WSPR 2-minute transmission schedule
13+ */
714class WSPRProcessor
815{
916 companion object
@@ -16,32 +23,37 @@ class WSPRProcessor
1623 // WSPR Protocol Constants
1724 private const val WSPR_SYMBOL_DURATION_SECONDS = 0.683f // Each symbol is ~0.683 seconds
1825 private const val WSPR_TRANSMISSION_DURATION_SECONDS = WSPR_SYMBOL_DURATION_SECONDS * SYMBOLS_PER_MESSAGE // ~110.6 seconds
26+ private const val WSPR_CYCLE_DURATION_SECONDS = 120f // WSPR transmits every 2 minutes
1927
2028 // Buffer Timing Constants
2129 private const val MINIMUM_DECODE_SECONDS = 120f // Minimum for decode attempt
2230 private const val RECOMMENDED_BUFFER_SECONDS = 180f // Recommended buffer for reliable decode (3 minutes for overlap)
23- private const val BUFFER_OVERLAP_SECONDS = RECOMMENDED_BUFFER_SECONDS - WSPR_TRANSMISSION_DURATION_SECONDS // ~60 seconds overlap
31+
32+ // Decode Window Strategy Constants
33+ private const val SLIDING_WINDOW_STEP_SECONDS = 30f // Step between sliding windows
34+ private const val MAX_DECODE_WINDOWS = 6 // Limit processing to prevent excessive CPU usage
2435
2536 // Buffer Size Calculations
2637 private const val MAXIMUM_BUFFER_SAMPLES = (SAMPLE_RATE_HZ * RECOMMENDED_BUFFER_SECONDS ).toInt()
2738 private const val MINIMUM_DECODE_SAMPLES = (SAMPLE_RATE_HZ * MINIMUM_DECODE_SECONDS ).toInt()
39+ private const val OPTIMAL_DECODE_SAMPLES = MINIMUM_DECODE_SAMPLES // Native decoder limit
2840 }
2941
30- private val audioBuffer = mutableListOf<Short >()
42+ val audioBuffer = mutableListOf<Short >()
3143
3244 /* *
3345 * Adds audio samples to the WSPR processing buffer.
46+ * Automatically manages buffer size to prevent memory issues.
3447 */
3548 fun addSamples (samples : ShortArray )
3649 {
3750 audioBuffer.addAll(samples.toList())
3851
39- // Maintain buffer size within limits
52+ // Maintain buffer size within limits using bulk removal
4053 if (audioBuffer.size > MAXIMUM_BUFFER_SAMPLES )
4154 {
4255 val samplesToRemove = audioBuffer.size - MAXIMUM_BUFFER_SAMPLES
4356
44- // TODO: Consider saving these to a different buffer
4557 repeat(samplesToRemove)
4658 {
4759 audioBuffer.removeAt(0 )
@@ -52,63 +64,198 @@ class WSPRProcessor
5264 /* *
5365 * Gets the current buffer duration in seconds.
5466 */
55- fun getBufferDurationSeconds (): Float
56- {
57- return audioBuffer.size.toFloat() / SAMPLE_RATE_HZ
58- }
67+ fun getBufferDurationSeconds (): Float = audioBuffer.size.toFloat() / SAMPLE_RATE_HZ
68+
69+ /* *
70+ * Checks if buffer has enough data for a WSPR decode attempt.
71+ */
72+ fun isReadyForDecode (): Boolean = audioBuffer.size >= MINIMUM_DECODE_SAMPLES
5973
6074 /* *
61- * Checks if buffer has enough data for a WSPR decode
75+ * Gets the optimal number of samples for WSPR decoding
76+ * Uses the minimum decode duration to avoid buffer overflow (CJarInterface.WSPRDecodeFromPcm expects 120 seconds)
6277 */
63- fun isReadyForDecode (): Boolean
78+ private fun getOptimalDecodeSamples (): Int
6479 {
65- return audioBuffer.size >= MINIMUM_DECODE_SAMPLES
80+ return ( SAMPLE_RATE_HZ * MINIMUM_DECODE_SECONDS ).toInt()
6681 }
6782
6883 /* *
69- * Decodes WSPR from buffered audio data.
84+ * Decodes WSPR from buffered audio data using the specified strategy.
85+ *
86+ * @param dialFrequencyMHz Radio dial frequency in MHz
87+ * @param useLowerSideband Whether to use LSB mode (inverts symbol order)
88+ * @param useTimeAlignment Use time-aligned windows (true) or sliding windows (false)
89+ * @return Array of decoded WSPR messages, or null if insufficient data
7090 */
7191 fun decodeBufferedWSPR (
7292 dialFrequencyMHz : Double = getDefaultFrequency(),
73- useLowerSideband : Boolean = false
93+ useLowerSideband : Boolean = false,
94+ useTimeAlignment : Boolean = false
7495 ): Array <WSPRMessage >?
7596 {
76- if (! isReadyForDecode()) { return null }
97+ if (! isReadyForDecode()) return null
98+
99+ val decodeWindows = if (useTimeAlignment)
100+ {
101+ generateTimeAlignedWindows()
102+ }
103+ else
104+ {
105+ generateSlidingWindows()
106+ }
77107
78- val audioBytes = convertShortsToBytes(audioBuffer.toShortArray())
79- return CJarInterface .WSPRDecodeFromPcm (audioBytes, dialFrequencyMHz, useLowerSideband)
108+ return processDecodeWindows(decodeWindows, dialFrequencyMHz, useLowerSideband)
80109 }
81110
82111 /* *
83- * Clears the audio buffer
112+ * Clears the audio buffer.
84113 */
85- fun clearBuffer ()
86- {
114+ fun clearBuffer () {
87115 audioBuffer.clear()
88116 }
89117
118+ // Public constants for external use
119+ fun getRecommendedBufferSeconds (): Float = RECOMMENDED_BUFFER_SECONDS
120+ fun getMinimumBufferSeconds (): Float = MINIMUM_DECODE_SECONDS
121+ fun getWSPRTransmissionSeconds (): Float = WSPR_TRANSMISSION_DURATION_SECONDS
122+ fun getBufferOverlapSeconds (): Float = RECOMMENDED_BUFFER_SECONDS - WSPR_TRANSMISSION_DURATION_SECONDS
123+
124+ // ========== Private Implementation ==========
125+
90126 /* *
91- * Gets recommended buffer duration for optimal WSPR decoding.
127+ * Represents a window of audio samples for WSPR decoding.
92128 */
93- fun getRecommendedBufferSeconds (): Float = RECOMMENDED_BUFFER_SECONDS
129+ private data class DecodeWindow (
130+ val startIndex : Int ,
131+ val endIndex : Int ,
132+ val description : String // For debugging/logging
133+ )
94134
95135 /* *
96- * Gets minimum buffer duration for WSPR decode attempts.
136+ * Generates overlapping sliding windows for WSPR decoding.
137+ * This attempts to catch WSPR transmissions that start at any time.
97138 */
98- fun getMinimumBufferSeconds (): Float = MINIMUM_DECODE_SECONDS
139+ private fun generateSlidingWindows (): List <DecodeWindow >
140+ {
141+ // Single window if buffer fits within decoder limits
142+ if (audioBuffer.size <= OPTIMAL_DECODE_SAMPLES )
143+ {
144+ return listOf (DecodeWindow (0 , audioBuffer.size, " Full buffer" ))
145+ }
146+
147+ val windows = mutableListOf<DecodeWindow >()
148+ val stepSamples = (SAMPLE_RATE_HZ * SLIDING_WINDOW_STEP_SECONDS ).toInt()
149+ val maxWindows = minOf(MAX_DECODE_WINDOWS , (audioBuffer.size - OPTIMAL_DECODE_SAMPLES ) / stepSamples + 1 )
150+
151+ for (windowIndex in 0 until maxWindows)
152+ {
153+ val startIndex = windowIndex * stepSamples
154+ val endIndex = startIndex + OPTIMAL_DECODE_SAMPLES
155+
156+ if (endIndex <= audioBuffer.size)
157+ {
158+ windows.add(DecodeWindow (
159+ startIndex,
160+ endIndex,
161+ " Sliding window ${windowIndex + 1 } (${startIndex / SAMPLE_RATE_HZ } s-${endIndex / SAMPLE_RATE_HZ } s)"
162+ ))
163+ }
164+ }
165+
166+ return windows
167+ }
99168
100169 /* *
101- * Gets the actual WSPR transmission duration
170+ * Generates time-aligned windows based on WSPR 2-minute transmission schedule.
171+ * This aligns with expected WSPR timing for decoding.
102172 */
103- fun getWSPRTransmissionSeconds (): Float = WSPR_TRANSMISSION_DURATION_SECONDS
173+ private fun generateTimeAlignedWindows (): List <DecodeWindow >
174+ {
175+ val windows = mutableListOf<DecodeWindow >()
176+ val cycleSamples = (SAMPLE_RATE_HZ * WSPR_CYCLE_DURATION_SECONDS ).toInt()
177+ val availableCycles = audioBuffer.size / cycleSamples
178+ val maxCycles = minOf(availableCycles, MAX_DECODE_WINDOWS )
179+
180+ for (cycle in 0 until maxCycles)
181+ {
182+ val startIndex = cycle * cycleSamples
183+ val endIndex = minOf(startIndex + OPTIMAL_DECODE_SAMPLES , audioBuffer.size)
184+
185+ // Ensure we have enough data to decode
186+ val windowDurationSeconds = (endIndex - startIndex.toFloat()) / SAMPLE_RATE_HZ
187+ if (windowDurationSeconds >= WSPR_TRANSMISSION_DURATION_SECONDS )
188+ {
189+ windows.add(DecodeWindow (
190+ startIndex,
191+ endIndex,
192+ description = " Time-aligned cycle ${cycle + 1 } (${startIndex / SAMPLE_RATE_HZ } s-${endIndex / SAMPLE_RATE_HZ } s)"
193+ ))
194+ }
195+ }
196+
197+ return windows
198+ }
104199
105200 /* *
106- * Gets the buffer overlap duration (extra buffering beyond transmission time)
201+ * Processes multiple decode windows and combines results.
202+ * Handles the actual native decoder calls and deduplication.
107203 */
108- fun getBufferOverlapSeconds (): Float = BUFFER_OVERLAP_SECONDS
204+ private fun processDecodeWindows (
205+ windows : List <DecodeWindow >,
206+ dialFrequencyMHz : Double ,
207+ useLowerSideband : Boolean
208+ ): Array <WSPRMessage >?
209+ {
210+ val allMessages = mutableListOf<WSPRMessage >()
211+
212+ for (window in windows)
213+ {
214+ try
215+ {
216+ val windowSamples = audioBuffer.subList(window.startIndex, window.endIndex).toShortArray()
217+ val audioBytes = convertShortsToBytes(windowSamples)
218+
219+ val messages = CJarInterface .WSPRDecodeFromPcm (audioBytes, dialFrequencyMHz, useLowerSideband)
220+
221+ messages?.let {
222+ allMessages.addAll(it.toList())
223+ // Timber.d("Decoded ${it.size} messages from ${window.description}")
224+ }
225+ }
226+ catch (exception: Exception )
227+ {
228+ // Log decode failure but continue with other windows
229+ // Timber.w(exception, "Failed to decode ${window.description}")
230+ }
231+ }
232+
233+ return if (allMessages.isNotEmpty())
234+ {
235+ removeDuplicateMessages(allMessages).toTypedArray()
236+ }
237+ else { return null }
238+ }
239+
240+ /* *
241+ * Removes duplicate WSPR messages based on content.
242+ */
243+ private fun removeDuplicateMessages (messages : List <WSPRMessage >): List <WSPRMessage >
244+ {
245+ return messages.distinctBy { message ->
246+ // Create unique key from message content
247+ val callsign = message.call ? : " UNKNOWN"
248+ val location = message.loc ? : " UNKNOWN"
249+ val power = message.power
250+ val snr = String .format(" %.1f" , message.getSNR()) // Round SNR to 1 decimal
251+
252+ " ${callsign} _${location} _${power} _${snr} "
253+ }
254+ }
109255
110256 /* *
111- * Converts 16-bit samples to byte array for AudioCoder processing
257+ * Converts 16-bit audio samples to byte array for native decoder.
258+ * Uses little-endian byte order as expected by the WSPR decoder.
112259 */
113260 private fun convertShortsToBytes (samples : ShortArray ): ByteArray
114261 {
0 commit comments