1+ package org.operatorfoundation.audiocoder
2+
3+ import timber.log.Timber
4+ import kotlin.math.roundToInt
5+
6+ /* *
7+ * Linear interpolation audio resampler for WSPR signals.
8+ *
9+ * Uses linear interpolation to convert between sample rates sufficient for WSPR's narrow band signals.
10+ *
11+ * This resampler maintains continuity between audio chunks by remembering
12+ * the last sample from the previous chunk for interpolation.
13+ *
14+ * Example usage:
15+ * ```kotlin
16+ * val resampler = AudioResampler(inputSampleRate = 48000, outputSampleRate = 12000)
17+ * val resampledAudio = resampler.resample(audioSamples)
18+ * ```
19+ */
20+ class AudioResampler (
21+ private val inputSampleRate : Int ,
22+ private val outputSampleRate : Int
23+ ) {
24+ /* *
25+ * Ratio for sample rate conversion calculation.
26+ */
27+ private val resampleRatio = outputSampleRate.toDouble() / inputSampleRate.toDouble()
28+
29+ /* *
30+ * Last sample from previous chunk, used for interpolation continuity.
31+ */
32+ private var lastSample: Short = 0
33+
34+ /* *
35+ * Statistics for monitoring resampler performance.
36+ */
37+ private var totalInputSamples = 0L
38+ private var totalOutputSamples = 0L
39+
40+ init {
41+ require(inputSampleRate > 0 ) { " Input sample rate must be positive: $inputSampleRate " }
42+ require(outputSampleRate > 0 ) { " Output sample rate must be positive: $outputSampleRate " }
43+
44+ Timber .d(" AudioResampler initialized: ${inputSampleRate} Hz -> ${outputSampleRate} Hz (ratio: %.3f)" .format(resampleRatio))
45+ }
46+
47+ /* *
48+ * Resamples input audio to the target sample rate using linear interpolation.
49+ *
50+ * @param inputSamples Raw 16-bit audio samples at the input sample rate
51+ * @return Resampled audio at the output sample rate
52+ */
53+ fun resample (inputSamples : ShortArray ): ShortArray
54+ {
55+ if (inputSamples.isEmpty()) {
56+ Timber .v(" Empty input samples, returning empty array" )
57+ return shortArrayOf()
58+ }
59+
60+ // No resampling needed if rates match
61+ if (inputSampleRate == outputSampleRate)
62+ {
63+ totalInputSamples + = inputSamples.size
64+ totalOutputSamples + = inputSamples.size
65+ return inputSamples
66+ }
67+
68+ // Calculate output length
69+ val outputLength = calculateOutputSize(inputSamples.size)
70+ val outputSamples = ShortArray (outputLength)
71+
72+ // Perform linear interpolation resampling
73+ for (i in outputSamples.indices)
74+ {
75+ val inputIndex = i / resampleRatio
76+ val inputIndexInt = inputIndex.toInt()
77+ val fraction = inputIndex - inputIndexInt
78+
79+ // Get the two samples to interpolate between
80+ val sample1 = getSampleForInterpolation(inputSamples, inputIndexInt)
81+ val sample2 = getSampleForInterpolation(inputSamples, inputIndexInt + 1 )
82+
83+ // Linear interpolation: sample1 + fraction * (sample2 - sample1)
84+ val interpolated = sample1 + (fraction * (sample2 - sample1))
85+
86+ // Clamp to 16-bit range and store
87+ outputSamples[i] = interpolated.roundToInt()
88+ .coerceIn(Short .MIN_VALUE .toInt(), Short .MAX_VALUE .toInt())
89+ .toShort()
90+ }
91+
92+ // Update statistics
93+ totalInputSamples + = inputSamples.size
94+ totalOutputSamples + = outputLength
95+
96+ // Remember last sample for next chunk continuity
97+ if (inputSamples.isNotEmpty()) {
98+ lastSample = inputSamples.last()
99+ }
100+
101+ return outputSamples
102+ }
103+
104+ /* *
105+ * Calculates the expected output size for a given input size.
106+ * Useful for pre-allocating buffers or estimating processing requirements.
107+ *
108+ * @param inputSize Number of input samples
109+ * @return Expected number of output samples
110+ */
111+ fun calculateOutputSize (inputSize : Int ): Int
112+ {
113+ return (inputSize * resampleRatio).roundToInt()
114+ }
115+
116+ /* *
117+ * Resets the resampler state, clearing interpolation continuity.
118+ * Call this when starting a new audio stream or after a discontinuity.
119+ */
120+ fun reset ()
121+ {
122+ lastSample = 0
123+ totalInputSamples = 0L
124+ totalOutputSamples = 0L
125+ Timber .v(" AudioResampler state reset" )
126+ }
127+
128+ /* *
129+ * Gets statistics about resampler performance.
130+ *
131+ * @return String with resampler statistics
132+ */
133+ fun getStatistics (): String
134+ {
135+ val compressionRatio = if (totalInputSamples > 0 )
136+ {
137+ totalOutputSamples.toDouble() / totalInputSamples.toDouble()
138+ }
139+ else
140+ {
141+ 0.0
142+ }
143+
144+ return " AudioResampler Stats: ${totalInputSamples} -> ${totalOutputSamples} samples " +
145+ " (ratio: %.3f, expected: %.3f)" .format(compressionRatio, resampleRatio)
146+ }
147+
148+ /* *
149+ * Gets a sample for interpolation, handling edge cases.
150+ *
151+ * @param samples Input sample array
152+ * @param index Requested sample index
153+ * @return Sample value for interpolation
154+ */
155+ private fun getSampleForInterpolation (samples : ShortArray , index : Int ): Short
156+ {
157+ return when {
158+ index < 0 -> lastSample // Use last sample from previous chunk
159+ index >= samples.size -> samples.last() // Use last available sample
160+ else -> samples[index] // Normal case
161+ }
162+ }
163+ }
0 commit comments