core: dsp: add decimator implementation

Co-authored-by: Ryan Turner <ryan@turnrye.com>
This commit is contained in:
Jetse Verschuren 2025-12-15 22:56:31 +01:00 committed by Silvano Seva
parent cec0043351
commit 7be721f312
8 changed files with 512 additions and 0 deletions

View file

@ -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)

View file

@ -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

View file

@ -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;
}

View file

@ -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
)

View 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);
}

View 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()

View 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)

View 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);
}