Skip to content

Commit e7416b1

Browse files
committed
Audio Resampler
1 parent db93252 commit e7416b1

File tree

2 files changed

+170
-0
lines changed

2 files changed

+170
-0
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
}

AudioCoder/src/main/jni/libloud.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,18 @@ Java_org_operatorfoundation_audiocoder_CJarInterface_WSPREncodeToPCM
4747
WSPR_SYMBOL_COUNT * WSPR_SYMBOL_LENGTH);
4848

4949
short volume = 16383;
50+
5051
for (int i = 0; i < WSPR_SYMBOL_COUNT; i++) {
5152
if (lsb_mod) {
5253
symbols[i] = (uint8_t) 3 - symbols[i];
5354
}
55+
56+
// Base band Carrier Frequency - 1500 Hz
57+
// Frequency spacing between the symbols - 1.4548
5458
double frequency = 1500 + ((int) j_offset) + symbols[i] * 1.4548;
59+
60+
// TODO: Create a new function that converts frequency (double) to ints ( * 100 + casting to UInt64) to Bytes and returns a byte array of the frequencies
61+
// Frequency array size = # of symbols * 8 bytes (size of 64 bit integer)
5562
double theta = frequency * TAU / (double) 12000;
5663
// 'volume' is UInt16 with range 0 thru Uint16.MaxValue ( = 65 535)
5764
// we need 'amp' to have the range of 0 thru Int16.MaxValue ( = 32 767)

0 commit comments

Comments
 (0)