Skip to content

Commit 878f548

Browse files
committed
Begin WSPR Timing
1 parent 32e6053 commit 878f548

File tree

12 files changed

+943
-148
lines changed

12 files changed

+943
-148
lines changed
Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,172 @@
11
package org.operatorfoundation.audiocoder
22

3-
interface WSPRAudioSource {
3+
import org.operatorfoundation.audiocoder.models.WSPRAudioSourceStatus
4+
import org.operatorfoundation.audiocoder.WSPRConstants.WSPR_REQUIRED_BIT_DEPTH
5+
import org.operatorfoundation.audiocoder.WSPRConstants.WSPR_REQUIRED_CHANNELS
6+
import org.operatorfoundation.audiocoder.WSPRConstants.WSPR_REQUIRED_SAMPLE_RATE
7+
8+
/**
9+
* Interface for providing audio data to WSPR station.
10+
*
11+
* Implementations must provide audio in the WSPR-required format:
12+
* - Sample rate: 12,000 Hz
13+
* - Bit depth: 16-bit signed integers
14+
* - Channels: Mono (single channel)
15+
* - Encoding: PCM (Pulse Code Modulation)
16+
*
17+
* The audio source should be designed for real-time operation and
18+
* handle buffering internally to provide smooth, continuous audio delivery.
19+
*
20+
* Example implementation:
21+
* class MyAudioSource : WSPRAudioSource {
22+
* override suspend fun initialize(): Result<Unit> {
23+
* // Set up audio hardware, open files, etc.
24+
* }
25+
*
26+
* override suspend fun readAudioChunk(durationMs: Long): ShortArray {
27+
* // Return audio samples for the requested duration
28+
* }
29+
* }
30+
*/
31+
interface WSPRAudioSource
32+
{
33+
/**
34+
* Initializes the audio source and prepares it for audio delivery.
35+
*
36+
* This method should:
37+
* - Configure audio hardware or open audio files
38+
* - Verify that the source can provide WSPR-compatible audio
39+
* - Set up any necessary buffering or processing pipelines
40+
* - Perform connectivity or permission checks
41+
*
42+
* The method should be idempotent - calling it multiple times should
43+
* not cause errors or resource leaks.
44+
*
45+
* @return Success if initialization completed without errors,
46+
* Failure with descriptive error information if initialization failed
47+
*
48+
* @throws WSPRAudioSourceException for unrecoverable initialization errors
49+
*/
50+
suspend fun initialize(): Result<Unit>
51+
52+
/**
53+
* Reads a chunk of audio data covering the specified time duration.
54+
*
55+
* This method provides audio samples in real-time for WSPR processing.
56+
* It should return approximately the number of samples corresponding
57+
* to the requested duration at 12kHz sample rate.
58+
*
59+
* Expected sample count calculation:
60+
* ```
61+
* sampleCount = (durationMs / 1000.0) * WSPR_SAMPLE_RATE_HZ
62+
* ```
63+
*
64+
* Behavior requirements:
65+
* - Returns audio promptly without excessive blocking
66+
* - Provides continuous audio stream (no gaps between calls)
67+
* - Handles timing variations gracefully
68+
* - Returns empty array if no audio is available
69+
*
70+
* @param durationMs Requested audio duration in milliseconds
71+
* @return Array of 16-bit audio samples covering the requested duration.
72+
* May be shorter than requested if insufficient audio is available.
73+
* Should not be longer than requested to prevent buffer overflow.
74+
*
75+
* @throws WSPRAudioSourceException for unrecoverable read errors
76+
*/
77+
suspend fun readAudioChunk(durationMs: Long): ShortArray
78+
79+
/**
80+
* Releases all resources and stops audio acquisition.
81+
*
82+
* This method should:
83+
* - Stop any active audio recording or streaming
84+
* - Release hardware resources (USB connections, audio devices)
85+
* - Close open files or network connections
86+
* - Free memory buffers
87+
* - Cancel any background processing tasks
88+
*
89+
* After calling this method, the audio source should not be used
90+
* until initialize() is called again.
91+
*
92+
* The method should be safe to call multiple times and should not
93+
* throw exceptions even if cleanup encounters errors.
94+
*/
95+
suspend fun cleanup()
96+
97+
/**
98+
* Gets current status and diagnostic information about the audio source.
99+
*
100+
* This method provides information useful for:
101+
* - Troubleshooting audio issues
102+
* - Monitoring source health and performance
103+
* - Displaying status in user interfaces
104+
*
105+
* @return Current status and diagnostic information
106+
*/
107+
suspend fun getSourceStatus(): WSPRAudioSourceStatus
108+
}
109+
110+
/**
111+
* Exception thrown by WSPR audio source implementations.
112+
*/
113+
class WSPRAudioSourceException(
114+
message: String,
115+
cause: Throwable? = null
116+
) : Exception(message, cause)
117+
{
118+
companion object
119+
{
120+
/**
121+
* Creates an exception for initialization failures.
122+
*
123+
* @param sourceDescription Type or name of the audio source
124+
* @param cause Underlying cause of the failure
125+
* @return Formatted exception with descriptive message
126+
*/
127+
fun createInitializationFailure(sourceDescription: String, cause: Throwable? = null): WSPRAudioSourceException
128+
{
129+
return WSPRAudioSourceException(
130+
"Failed to initialize WSPR audio source: $sourceDescription. ${cause?.message ?: "Unknown error"}",
131+
cause
132+
)
133+
}
134+
135+
/**
136+
* Creates an exception for audio reading failures.
137+
*
138+
* @param cause Underlying cause of the read failure
139+
* @return Formatted exception with descriptive message
140+
*/
141+
fun createReadFailure(cause: Throwable? = null): WSPRAudioSourceException
142+
{
143+
return WSPRAudioSourceException(
144+
"Failed to read audio data from WSPR source. ${cause?.message ?: "Unknown error"}",
145+
cause
146+
)
147+
}
148+
149+
/**
150+
* Creates an exception for audio format compatibility issues.
151+
*
152+
* @param actualSampleRate Actual sample rate provided by source
153+
* @param actualChannels Actual channel count provided by source
154+
* @param actualBitDepth Actual bit depth provided by source
155+
* @return Formatted exception describing the compatibility issue
156+
*/
157+
fun createFormatIncompatibility(
158+
actualSampleRate: Int,
159+
actualChannels: Int,
160+
actualBitDepth: Int
161+
): WSPRAudioSourceException
162+
{
163+
return WSPRAudioSourceException(
164+
"Audio source format incompatible with WSPR requirements. " +
165+
"Required: ${WSPR_REQUIRED_SAMPLE_RATE}Hz, " +
166+
"${WSPR_REQUIRED_CHANNELS} channel, " +
167+
"${WSPR_REQUIRED_BIT_DEPTH}-bit. " +
168+
"Actual: ${actualSampleRate}Hz, ${actualChannels} channels, ${actualBitDepth}-bit."
169+
)
170+
}
171+
}
4172
}

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,63 @@ package org.operatorfoundation.audiocoder
22

33
object WSPRConstants
44
{
5-
const val SAMPLE_RATE_HZ = 12000 // WSPR uses 12kHz sample rate
65
const val CENTER_FREQUENCY_HZ = 1500
76
const val SYMBOL_LENGTH = 8192
87
const val SYMBOLS_PER_MESSAGE = 162 // WSPR messages have 162 symbols
8+
9+
/** WSPR requires exactly 12kHz sample rate */
10+
const val WSPR_REQUIRED_SAMPLE_RATE = 12000
11+
12+
/** WSPR requires mono audio (1 channel) */
13+
const val WSPR_REQUIRED_CHANNELS = 1
14+
15+
/** WSPR requires 16-bit audio samples */
16+
const val WSPR_REQUIRED_BIT_DEPTH = 16
17+
}
18+
19+
/**
20+
* WSPR timing constants used throughout the station implementation.
21+
*/
22+
object WSPRTimingConstants
23+
{
24+
/** Standard WSPR cycle duration: 2 minutes */
25+
const val WSPR_CYCLE_DURATION_SECONDS = 120L
26+
27+
/** WSPR transmission duration: approximately 110.g seconds */
28+
const val WSPR_TRANSMISSION_DURATION_SECONDS = 111L
29+
30+
/** Native decoder audio collection requirement: exactly 114 seconds */
31+
const val AUDIO_COLLECTION_DURATION_SECONDS = 114L
32+
const val AUDIO_COLLECTION_DURATION_MILLISECONDS = AUDIO_COLLECTION_DURATION_SECONDS * 1000L
33+
34+
/** Delay before starting decode after transmission begins: 2 seconds */
35+
const val DECODE_START_DELAY_SECONDS = 2L
36+
37+
/** Duration of each audio chunk read during collection: 1 second */
38+
const val AUDIO_CHUNK_DURATION_MILLISECONDS = 1000L
39+
40+
/** Pause between audio chunk reads: 100ms */
41+
const val AUDIO_COLLECTION_PAUSE_MILLISECONDS = 100L
42+
43+
/** How often to update cycle information for UI: 1 second */
44+
const val CYCLE_INFORMATION_UPDATE_INTERVAL_MILLISECONDS = 1000L
45+
46+
/** Brief pause between operations: 2 seconds */
47+
const val BRIEF_OPERATION_PAUSE_MILLISECONDS = 2000L
48+
49+
/** Maximum delay for error backoff: 5 minutes */
50+
const val MAXIMUM_ERROR_BACKOFF_MILLISECONDS = 300_000L
51+
52+
/** WSPR operates on 2-minute cycles */
53+
const val MINUTES_PER_WSPR_CYCLE = 2
54+
55+
/** Standard calendar constants */
56+
const val MINUTES_PER_HOUR = 60
57+
const val SECONDS_PER_MINUTE = 60
58+
59+
/**
60+
* Decode window closes at 116 seconds to ensure we have collected the required
61+
* 114 seconds of audio while staying within the transmission period
62+
*/
63+
const val DECODE_WINDOW_END_SECOND = 116
964
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import android.content.Context
44
import android.content.Intent
55
import android.icu.text.SimpleDateFormat
66
import androidx.core.content.FileProvider
7-
import org.operatorfoundation.audiocoder.WSPRConstants.SAMPLE_RATE_HZ
7+
import org.operatorfoundation.audiocoder.WSPRConstants.WSPR_REQUIRED_SAMPLE_RATE
88
import timber.log.Timber
99
import java.io.File
1010
import java.io.FileOutputStream
@@ -84,8 +84,8 @@ class WSPRFileManager(private val context: Context)
8484
putInt(16) // PCM format chunk size
8585
putShort(1) // PCM format
8686
putShort(CHANNELS.toShort())
87-
putInt(SAMPLE_RATE_HZ)
88-
putInt(SAMPLE_RATE_HZ * CHANNELS * BITS_PER_SAMPLE / 8) // Byte rate
87+
putInt(WSPR_REQUIRED_SAMPLE_RATE)
88+
putInt(WSPR_REQUIRED_SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8) // Byte rate
8989
putShort((CHANNELS * BITS_PER_SAMPLE / 8).toShort()) // Block align
9090
putShort(BITS_PER_SAMPLE.toShort())
9191

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package org.operatorfoundation.audiocoder
22

33
import org.operatorfoundation.audiocoder.WSPRBandplan.getDefaultFrequency
4-
import org.operatorfoundation.audiocoder.WSPRConstants.SAMPLE_RATE_HZ
4+
import org.operatorfoundation.audiocoder.WSPRConstants.WSPR_REQUIRED_SAMPLE_RATE
55
import org.operatorfoundation.audiocoder.WSPRConstants.SYMBOLS_PER_MESSAGE
66

77
/**
@@ -34,8 +34,8 @@ class WSPRProcessor
3434
private const val MAX_DECODE_WINDOWS = 6 // Limit processing to prevent excessive CPU usage
3535

3636
// Buffer Size Calculations
37-
private const val MAXIMUM_BUFFER_SAMPLES = (SAMPLE_RATE_HZ * RECOMMENDED_BUFFER_SECONDS).toInt()
38-
private const val REQUIRED_DECODE_SAMPLES = (SAMPLE_RATE_HZ * REQUIRED_DECODE_SECONDS).toInt() // Native decoder limit
37+
private const val MAXIMUM_BUFFER_SAMPLES = (WSPR_REQUIRED_SAMPLE_RATE * RECOMMENDED_BUFFER_SECONDS).toInt()
38+
private const val REQUIRED_DECODE_SAMPLES = (WSPR_REQUIRED_SAMPLE_RATE * REQUIRED_DECODE_SECONDS).toInt() // Native decoder limit
3939
}
4040

4141
val audioBuffer = mutableListOf<Short>()
@@ -63,7 +63,7 @@ class WSPRProcessor
6363
/**
6464
* Gets the current buffer duration in seconds.
6565
*/
66-
fun getBufferDurationSeconds(): Float = audioBuffer.size.toFloat() / SAMPLE_RATE_HZ
66+
fun getBufferDurationSeconds(): Float = audioBuffer.size.toFloat() / WSPR_REQUIRED_SAMPLE_RATE
6767

6868
/**
6969
* Checks if buffer has enough data for a WSPR decode attempt.
@@ -140,7 +140,7 @@ class WSPRProcessor
140140
}
141141

142142
val windows = mutableListOf<DecodeWindow>()
143-
val stepSamples = (SAMPLE_RATE_HZ * SLIDING_WINDOW_STEP_SECONDS).toInt()
143+
val stepSamples = (WSPR_REQUIRED_SAMPLE_RATE * SLIDING_WINDOW_STEP_SECONDS).toInt()
144144
val maxWindows = minOf(MAX_DECODE_WINDOWS, (audioBuffer.size - REQUIRED_DECODE_SAMPLES) / stepSamples + 1)
145145

146146
for (windowIndex in 0 until maxWindows)
@@ -153,7 +153,7 @@ class WSPRProcessor
153153
windows.add(DecodeWindow(
154154
startIndex,
155155
endIndex,
156-
"Sliding window ${windowIndex + 1} (${startIndex / SAMPLE_RATE_HZ}s-${endIndex / SAMPLE_RATE_HZ}s)"
156+
"Sliding window ${windowIndex + 1} (${startIndex / WSPR_REQUIRED_SAMPLE_RATE}s-${endIndex / WSPR_REQUIRED_SAMPLE_RATE}s)"
157157
))
158158
}
159159
}
@@ -168,7 +168,7 @@ class WSPRProcessor
168168
private fun generateTimeAlignedWindows(): List<DecodeWindow>
169169
{
170170
val windows = mutableListOf<DecodeWindow>()
171-
val cycleSamples = (SAMPLE_RATE_HZ * WSPR_CYCLE_DURATION_SECONDS).toInt()
171+
val cycleSamples = (WSPR_REQUIRED_SAMPLE_RATE * WSPR_CYCLE_DURATION_SECONDS).toInt()
172172
val availableCycles = audioBuffer.size / cycleSamples
173173
val maxCycles = minOf(availableCycles, MAX_DECODE_WINDOWS)
174174

@@ -178,13 +178,13 @@ class WSPRProcessor
178178
val endIndex = minOf(startIndex + REQUIRED_DECODE_SAMPLES, audioBuffer.size)
179179

180180
// Ensure we have enough data to decode
181-
val windowDurationSeconds = (endIndex - startIndex.toFloat()) / SAMPLE_RATE_HZ
181+
val windowDurationSeconds = (endIndex - startIndex.toFloat()) / WSPR_REQUIRED_SAMPLE_RATE
182182
if (windowDurationSeconds >= WSPR_TRANSMISSION_DURATION_SECONDS)
183183
{
184184
windows.add(DecodeWindow(
185185
startIndex,
186186
endIndex,
187-
description = "Time-aligned cycle ${cycle + 1} (${startIndex / SAMPLE_RATE_HZ}s-${endIndex / SAMPLE_RATE_HZ}s)"
187+
description = "Time-aligned cycle ${cycle + 1} (${startIndex / WSPR_REQUIRED_SAMPLE_RATE}s-${endIndex / WSPR_REQUIRED_SAMPLE_RATE}s)"
188188
))
189189
}
190190
}

0 commit comments

Comments
 (0)