mirror of
https://github.com/OpenRTX/OpenRTX
synced 2026-08-08 12:29:06 -04:00
core: dsp: add decimator implementation
Co-authored-by: Ryan Turner <ryan@turnrye.com>
This commit is contained in:
parent
cec0043351
commit
7be721f312
8 changed files with 512 additions and 0 deletions
|
|
@ -1135,6 +1135,10 @@ m17_packet_test = executable('m17_packet_test',
|
|||
sources : unit_test_src + ['tests/unit/M17_packet.cpp'],
|
||||
kwargs : unit_test_opts)
|
||||
|
||||
dsp_oversampling_test = executable('dsp_oversampling_test',
|
||||
sources : unit_test_src + ['tests/unit/dsp_oversampling.cpp'],
|
||||
kwargs : unit_test_opts)
|
||||
|
||||
test('M17 Golay Unit Test', m17_golay_test)
|
||||
test('M17 Viterbi Unit Test', m17_viterbi_test)
|
||||
test('M17 Demodulator Test', m17_demodulator_test)
|
||||
|
|
@ -1146,3 +1150,4 @@ test('Codeplug Test', cps_test)
|
|||
test('minmea conversion Test', minmea_conversion_test)
|
||||
test('UI Check Standby Test', ui_check_standby_test)
|
||||
test('M17 Packet Frame Test', m17_packet_test)
|
||||
test('DSP Oversampling Test', dsp_oversampling_test)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,29 @@ static inline void dsp_removeDcOffset(struct dcBlock *dcb, int16_t *buffer,
|
|||
buffer[i] = dsp_dcBlockFilter(dcb, buffer[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data structure holding the internal state of a decimation block.
|
||||
*/
|
||||
struct decimatorState {
|
||||
int32_t accumulator;
|
||||
uint32_t count;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Run a single step of the oversampling decimation filter.
|
||||
*
|
||||
* Accumulates input samples and returns their truncated integer average
|
||||
* once the oversampling factor number of samples have been collected.
|
||||
*
|
||||
* @param state: pointer to the decimator state.
|
||||
* @param sample: pointer to the audio sample. Contains the value of the
|
||||
* decimated output when the function returns true.
|
||||
* @param ratio: sample decimation ratio.
|
||||
* @return true when a decimated sample is ready, false otherwise.
|
||||
*/
|
||||
bool dsp_decimator(struct decimatorState *state, int16_t *sample,
|
||||
uint16_t ratio);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif // __cplusplus
|
||||
|
|
|
|||
|
|
@ -23,3 +23,20 @@ int16_t dsp_dcBlockFilter(struct dcBlock *dcb, int16_t sample)
|
|||
|
||||
return static_cast<int16_t>(dcb->prevOut);
|
||||
}
|
||||
|
||||
bool dsp_decimator(struct decimatorState *state, int16_t *sample,
|
||||
uint16_t ratio)
|
||||
{
|
||||
state->accumulator += *sample;
|
||||
state->count++;
|
||||
if (state->count >= ratio) {
|
||||
// Integer division truncates toward zero; this is intentional as
|
||||
// rounding is unnecessary for audio signal averaging.
|
||||
*sample = state->accumulator / ratio;
|
||||
state->accumulator = 0;
|
||||
state->count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ tests/unit/M17_viterbi.cpp
|
|||
tests/unit/ui_check_standby.cpp
|
||||
tests/unit/M17_metatext.cpp
|
||||
tests/unit/M17_packet.cpp
|
||||
tests/unit/dsp_oversampling.cpp
|
||||
EOF
|
||||
)
|
||||
|
||||
|
|
|
|||
105
tests/platform/oversample_test.c
Normal file
105
tests/platform/oversample_test.c
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: Copyright 2020-2026 OpenRTX Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "core/audio_stream.h"
|
||||
#include "core/audio_path.h"
|
||||
#include "interfaces/platform.h"
|
||||
#include "interfaces/delays.h"
|
||||
#include "core/memory_profiling.h"
|
||||
#include "interfaces/audio.h"
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "core/dsp.h"
|
||||
|
||||
static const size_t audioBufSize = 320;
|
||||
static const size_t outBufSize = 45 * 1024;
|
||||
// Value in range [1, 16] any larger will overflow the uint16_t and bit shifting will become necessary
|
||||
static const size_t oversample = 4;
|
||||
// Is essentially sqrt(oversample)/2
|
||||
static const size_t oversample_bits = 0;
|
||||
|
||||
void error()
|
||||
{
|
||||
while (1) {
|
||||
platform_ledOn(RED);
|
||||
sleepFor(0u, 500u);
|
||||
platform_ledOff(RED);
|
||||
sleepFor(0u, 500u);
|
||||
}
|
||||
}
|
||||
|
||||
void blink_green(size_t count)
|
||||
{
|
||||
for (size_t i = 0; count == 0 || i < count; i++)
|
||||
{
|
||||
platform_ledOn(GREEN);
|
||||
sleepFor(0u, 500u);
|
||||
platform_ledOff(GREEN);
|
||||
sleepFor(0u, 500u);
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
platform_init();
|
||||
|
||||
int16_t *audioBuf = ((int16_t *)malloc(audioBufSize * sizeof(int16_t)));
|
||||
if (audioBuf == NULL)
|
||||
error();
|
||||
uint16_t *outBuf = ((uint16_t *)malloc(outBufSize * sizeof(uint16_t)));
|
||||
if (outBuf == NULL)
|
||||
error();
|
||||
|
||||
// Requesting the audio path enables the preamp and some other stuff, this needs a few seconds to settle before ready
|
||||
pathId path = audioPath_request(SOURCE_MIC, SINK_MCU, PRIO_TX);
|
||||
|
||||
blink_green(3);
|
||||
platform_ledOn(RED);
|
||||
|
||||
streamId id = audioStream_start(path, audioBuf, audioBufSize, 8000 * oversample,
|
||||
BUF_CIRC_DOUBLE | STREAM_INPUT);
|
||||
|
||||
size_t outPos = 0;
|
||||
size_t subPos = 0;
|
||||
uint32_t sum = 0;
|
||||
|
||||
while (true) {
|
||||
dataBlock_t data = inputStream_getData(id);
|
||||
|
||||
if (data.data == NULL)
|
||||
error();
|
||||
|
||||
for (size_t i = 0; i < data.len; i++)
|
||||
{
|
||||
sum += (uint16_t)data.data[i];
|
||||
subPos++;
|
||||
if (subPos >= oversample)
|
||||
{
|
||||
subPos = 0;
|
||||
outBuf[outPos++] = sum >> oversample_bits;
|
||||
sum = 0;
|
||||
if (outPos >= outBufSize)
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
}
|
||||
done:
|
||||
|
||||
audioStream_stop(id);
|
||||
|
||||
platform_ledOff(RED);
|
||||
blink_green(10);
|
||||
platform_ledOn(RED);
|
||||
|
||||
for (size_t i = 0; i < outBufSize; i++)
|
||||
iprintf("%04x\n", outBuf[i]);
|
||||
|
||||
platform_ledOff(RED);
|
||||
|
||||
blink_green(0);
|
||||
}
|
||||
181
tests/platform/oversample_test.py
Normal file
181
tests/platform/oversample_test.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#
|
||||
# SPDX-FileCopyrightText: Copyright 2020-2026 OpenRTX Contributors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
|
||||
import serial
|
||||
import time
|
||||
import argparse
|
||||
import csv
|
||||
import numpy as np
|
||||
from scipy.io.wavfile import write
|
||||
import sys
|
||||
|
||||
def connect_serial(port, baudrate, retries=60, delay=1):
|
||||
"""
|
||||
Connects to the serial port with retries and shows a waiting indicator.
|
||||
"""
|
||||
spinner_chars = ['-', '\\', '|', '/']
|
||||
print(f"Attempting to connect to serial port {port}...", flush=True)
|
||||
|
||||
for i in range(retries):
|
||||
try:
|
||||
ser = serial.Serial(port, baudrate, timeout=1)
|
||||
print("\rSuccessfully connected to serial port. ") # Clear spinner
|
||||
return ser
|
||||
except serial.SerialException:
|
||||
sys.stdout.write(f"\rAttempt {i + 1}/{retries} {spinner_chars[i % len(spinner_chars)]}")
|
||||
sys.stdout.flush()
|
||||
time.sleep(delay)
|
||||
print("\rFailed to connect to serial port after multiple retries. ") # Clear spinner
|
||||
return None
|
||||
|
||||
def read_and_decode_data(ser, buffer_size):
|
||||
"""
|
||||
Reads data from the serial port, decodes hex values, and returns a list of integers,
|
||||
showing a progress bar.
|
||||
"""
|
||||
data = []
|
||||
print(f"Receiving {buffer_size} values from serial...")
|
||||
progress_bar_length = 50
|
||||
start_time = time.time()
|
||||
|
||||
while len(data) < buffer_size:
|
||||
line = ser.readline().strip()
|
||||
if line:
|
||||
try:
|
||||
hex_value = line.decode('ascii')
|
||||
data_value = int(hex_value, 16)
|
||||
data.append(data_value)
|
||||
|
||||
# Update progress bar
|
||||
current_progress = len(data)
|
||||
percentage = (current_progress / buffer_size) * 100
|
||||
filled_length = int(progress_bar_length * current_progress / buffer_size)
|
||||
bar = '█' * filled_length + '-' * (progress_bar_length - filled_length)
|
||||
|
||||
# Estimate remaining time
|
||||
elapsed_time = time.time() - start_time
|
||||
if current_progress > 0:
|
||||
time_per_item = elapsed_time / current_progress
|
||||
remaining_items = buffer_size - current_progress
|
||||
estimated_remaining_time = remaining_items * time_per_item
|
||||
time_str = f" {estimated_remaining_time:.1f}s remaining"
|
||||
else:
|
||||
time_str = ""
|
||||
|
||||
sys.stdout.write(f'\rProgress: |{bar}| {percentage:.1f}% ({current_progress}/{buffer_size}){time_str}')
|
||||
sys.stdout.flush()
|
||||
except (UnicodeDecodeError, ValueError) as e:
|
||||
# Clear current line before printing warning
|
||||
sys.stdout.write('\r' + ' ' * (progress_bar_length + 60) + '\r')
|
||||
sys.stdout.flush()
|
||||
print(f"Warning: Could not decode or parse line: {line}. Error: {e}")
|
||||
# Re-draw progress bar
|
||||
current_progress = len(data) # Recalculate based on actual data
|
||||
percentage = (current_progress / buffer_size) * 100
|
||||
filled_length = int(progress_bar_length * current_progress / buffer_size)
|
||||
bar = '█' * filled_length + '-' * (progress_bar_length - filled_length)
|
||||
sys.stdout.write(f'\rProgress: |{bar}| {percentage:.1f}% ({current_progress}/{buffer_size})')
|
||||
sys.stdout.flush()
|
||||
|
||||
sys.stdout.write('\n') # New line after progress bar is complete
|
||||
print(f"Received {len(data)} values.")
|
||||
return data
|
||||
|
||||
def normalize_audio(audio_data):
|
||||
"""
|
||||
Normalizes audio data: converts to float, removes DC offset, and scales for no clipping.
|
||||
The output is a float array scaled between -1.0 and 1.0.
|
||||
"""
|
||||
#audio_float = (np.array(audio_data, dtype=np.float64) - 32767.5) / 32768.0
|
||||
audio_float = np.array(audio_data, dtype=np.float32)
|
||||
|
||||
dc_offset = np.mean(audio_float)
|
||||
audio_dc_removed = audio_float - dc_offset
|
||||
|
||||
max_abs_val = np.max(np.abs(audio_dc_removed))
|
||||
|
||||
if max_abs_val > 0:
|
||||
scaling_factor = 1.0 / max_abs_val
|
||||
audio_normalized = audio_dc_removed * scaling_factor
|
||||
else:
|
||||
audio_normalized = audio_dc_removed
|
||||
|
||||
audio_normalized = np.clip(audio_normalized, -1.0, 1.0)
|
||||
|
||||
#return audio_normalized.astype(np.float32)
|
||||
return audio_normalized
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read audio data from microcontroller, save to CSV and WAV."
|
||||
)
|
||||
parser.add_argument(
|
||||
"filename_base",
|
||||
type=str,
|
||||
help="Base filename for CSV and WAV files (e.g., 'audio_capture')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=str,
|
||||
default="/dev/ttyACM",
|
||||
help="Serial port (e.g., /dev/ttyACM0 or COM3)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baudrate",
|
||||
type=int,
|
||||
default=115200,
|
||||
help="Serial baud rate"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--buffer_size",
|
||||
type=int,
|
||||
default=45 * 1024,
|
||||
help="Number of 16-bit unsigned integers in the microcontroller's buffer"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--samplerate",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Sample rate of the microphone in Hz"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
csv_filename = f"{args.filename_base}.csv"
|
||||
wav_filename = f"{args.filename_base}.wav"
|
||||
|
||||
ser = connect_serial(args.port, args.baudrate)
|
||||
if not ser:
|
||||
return
|
||||
|
||||
try:
|
||||
raw_data = read_and_decode_data(ser, args.buffer_size)
|
||||
|
||||
print(f"Saving raw data to {csv_filename}...")
|
||||
with open(csv_filename, 'w', newline='') as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
#writer.writerow(['Raw_Value'])
|
||||
for value in raw_data:
|
||||
writer.writerow([value])
|
||||
print("Raw data saved to CSV.")
|
||||
|
||||
if raw_data:
|
||||
print("Normalizing audio data to float format...")
|
||||
normalized_audio_float = normalize_audio(raw_data)
|
||||
|
||||
print(f"Saving normalized audio to {wav_filename} (float format)...")
|
||||
write(wav_filename, args.samplerate, normalized_audio_float)
|
||||
print("Normalized audio saved to WAV.")
|
||||
else:
|
||||
print("No audio data to normalize or save to WAV.")
|
||||
|
||||
finally:
|
||||
ser.close()
|
||||
print("Serial connection closed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
85
tests/platform/oversample_test_analyse.py
Normal file
85
tests/platform/oversample_test_analyse.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#
|
||||
# SPDX-FileCopyrightText: Copyright 2020-2026 OpenRTX Contributors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import math
|
||||
from scipy.io.wavfile import write
|
||||
import numpy as np
|
||||
|
||||
def normalize_audio(audio_data):
|
||||
"""
|
||||
Normalizes audio data: converts to float, removes DC offset, and scales for no clipping.
|
||||
The output is a float array scaled between -1.0 and 1.0.
|
||||
"""
|
||||
#audio_float = (np.array(audio_data, dtype=np.float64) - 32767.5) / 32768.0
|
||||
audio_float = np.array(audio_data, dtype=np.float32)
|
||||
|
||||
dc_offset = np.mean(audio_float)
|
||||
audio_dc_removed = audio_float - dc_offset
|
||||
|
||||
max_abs_val = np.max(np.abs(audio_dc_removed))
|
||||
|
||||
if max_abs_val > 0:
|
||||
scaling_factor = 1.0 / max_abs_val
|
||||
audio_normalized = audio_dc_removed * scaling_factor
|
||||
else:
|
||||
audio_normalized = audio_dc_removed
|
||||
|
||||
audio_normalized = np.clip(audio_normalized, -1.0, 1.0)
|
||||
#print(dc_offset, max_abs_val, np.max(np.abs(audio_normalized)))
|
||||
|
||||
#return audio_normalized.astype(np.float32)
|
||||
return audio_normalized
|
||||
|
||||
def calculate_stats(directory_path):
|
||||
print("File\t\tDC offset\tRMS")
|
||||
files_to_process = []
|
||||
for filename in os.listdir(directory_path):
|
||||
if filename.endswith(".csv"):
|
||||
files_to_process.append(filename)
|
||||
|
||||
files_to_process.sort()
|
||||
|
||||
for filename in files_to_process:
|
||||
filepath = os.path.join(directory_path, filename)
|
||||
numbers = []
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
for line in f:
|
||||
try:
|
||||
numbers.append(float(line.strip()))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
normalized_audio_float = normalize_audio(numbers[1:])
|
||||
wav_filename = filepath.replace('.csv', '.wav')
|
||||
write(wav_filename, 8000, normalized_audio_float)
|
||||
|
||||
if numbers:
|
||||
# Calculate DC offset (average)
|
||||
dc_offset = sum(numbers) / len(numbers)
|
||||
|
||||
# Subtract DC offset and calculate sum of squares for RMS
|
||||
sum_sq_after_dc = 0.0
|
||||
for num in numbers:
|
||||
sum_sq_after_dc += (num - dc_offset) ** 2
|
||||
|
||||
rms_after_dc = math.sqrt(sum_sq_after_dc / len(numbers))
|
||||
|
||||
print(f"{filename}\t{dc_offset:8.1f}\t{rms_after_dc:8.2f}")
|
||||
else:
|
||||
print(f"{filename}\tN/A")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Calculate RMS after subtracting DC offset from single-column CSV files.")
|
||||
parser.add_argument("path", help="Path to the directory containing CSV files.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.path):
|
||||
print(f"Error: Directory not found at '{args.path}'")
|
||||
else:
|
||||
calculate_stats(args.path)
|
||||
95
tests/unit/dsp_oversampling.cpp
Normal file
95
tests/unit/dsp_oversampling.cpp
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: Copyright 2020-2026 OpenRTX Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "core/dsp.h"
|
||||
|
||||
TEST_CASE("Oversampling decimation with factor 1 passes through",
|
||||
"[dsp][oversampling]")
|
||||
{
|
||||
struct decimatorState blk;
|
||||
dsp_resetState(blk);
|
||||
|
||||
int16_t sample = 1000;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 1) == true);
|
||||
REQUIRE(sample == 1000);
|
||||
}
|
||||
|
||||
TEST_CASE("Oversampling decimation with factor 4 accumulates and averages",
|
||||
"[dsp][oversampling]")
|
||||
{
|
||||
struct decimatorState blk;
|
||||
dsp_resetState(blk);
|
||||
|
||||
int16_t sample;
|
||||
|
||||
sample = 100;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 4) == false);
|
||||
|
||||
sample = 200;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 4) == false);
|
||||
|
||||
sample = 300;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 4) == false);
|
||||
|
||||
sample = 400;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 4) == true);
|
||||
// Average of 100, 200, 300, 400 = 250
|
||||
REQUIRE(sample == 250);
|
||||
}
|
||||
|
||||
TEST_CASE("Oversampling decimation with factor 8 does not overflow",
|
||||
"[dsp][oversampling]")
|
||||
{
|
||||
struct decimatorState blk;
|
||||
dsp_resetState(blk);
|
||||
|
||||
// Feed 8 samples at ADC full-scale (12-bit: 4095); sum = 8 * 4095 = 32760
|
||||
// The uint32_t accumulator must handle this without overflow.
|
||||
int16_t sample;
|
||||
for (int i = 0; i < 7; i++) {
|
||||
sample = 4095;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 8) == false);
|
||||
}
|
||||
|
||||
sample = 4095;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 8) == true);
|
||||
REQUIRE(sample == 4095);
|
||||
}
|
||||
|
||||
TEST_CASE("Oversampling decimation resets between frames",
|
||||
"[dsp][oversampling]")
|
||||
{
|
||||
struct decimatorState blk;
|
||||
dsp_resetState(blk);
|
||||
|
||||
int16_t sample;
|
||||
|
||||
// First pair
|
||||
sample = 100;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 2) == false);
|
||||
sample = 200;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 2) == true);
|
||||
REQUIRE(sample == 150);
|
||||
|
||||
// Second pair should start fresh
|
||||
sample = 500;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 2) == false);
|
||||
sample = 700;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 2) == true);
|
||||
REQUIRE(sample == 600);
|
||||
}
|
||||
|
||||
TEST_CASE("Oversampling decimation with factor 1 and zero returns immediately",
|
||||
"[dsp][oversampling]")
|
||||
{
|
||||
struct decimatorState blk;
|
||||
dsp_resetState(blk);
|
||||
|
||||
int16_t sample = 0;
|
||||
REQUIRE(dsp_decimator(&blk, &sample, 1) == true);
|
||||
REQUIRE(sample == 0);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue