Skip to content

Commit c155a33

Browse files
committed
Buffer overload bug fix
1 parent 7b1d0e5 commit c155a33

File tree

1 file changed

+175
-28
lines changed

1 file changed

+175
-28
lines changed

AudioCoder/src/main/java/org/operatorfoundation/audiocoder/WSPRProcessor.kt

Lines changed: 175 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import org.operatorfoundation.audiocoder.WSPRBandplan.getDefaultFrequency
44
import org.operatorfoundation.audiocoder.WSPRConstants.SAMPLE_RATE_HZ
55
import 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+
*/
714
class 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

Comments
 (0)