Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions audioio/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ ffaudio/ffaudio/alsa.o: ffaudio/ffaudio/alsa.c
audioio.o: audioio.c std.h $(OBJS)
$(CC) $(CFLAGS) -std=gnu11 -c audioio.c -o audioio.o

audioio.a: audioio.o $(OBJS)
$(AR) rc audioio.a audioio.o $(OBJS)
resampler.o: resampler.c resampler.h
$(CC) $(CFLAGS) -std=gnu11 -c resampler.c -o resampler.o

audioio.a: audioio.o resampler.o $(OBJS)
$(AR) rc audioio.a audioio.o resampler.o $(OBJS)

.PHONY: clean
clean:
Expand Down
49 changes: 23 additions & 26 deletions audioio/audioio.c
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

#include "audioio.h"
#include "hermes_log.h"
#include "resampler.h"

extern volatile bool shutdown_;

Expand Down Expand Up @@ -534,6 +535,12 @@ void *radio_playback_thread(void *device_ptr)
// period_bytes at 8kHz (input rate) - adjust for the lower sample rate
uint32_t period_bytes_8k = period_bytes / resample_ratio;

/* Polyphase anti-imaging upsampler, stateful across periods (its filter
* history bridges read boundaries, so no per-period click — issue #81). */
resampler_global_init();
resamp_up_t up_rs;
resamp_up_reset(&up_rs);

while (!shutdown_ && !audio_shutdown_)
{
ffssize n;
Expand Down Expand Up @@ -561,19 +568,9 @@ void *radio_playback_thread(void *device_ptr)

int samples_read_8k = n / sizeof(int32_t);

// Upsample from 8kHz to 48kHz using linear interpolation
int samples_upsampled = samples_read_8k * resample_ratio;
for (int i = 0; i < samples_read_8k; i++)
{
int32_t current = input_buffer[i];
int32_t next = (i + 1 < samples_read_8k) ? input_buffer[i + 1] : current;

for (int j = 0; j < resample_ratio; j++)
{
// Linear interpolation between current and next sample
buffer_upsampled[i * resample_ratio + j] = current + (next - current) * j / resample_ratio;
}
}
// Upsample 8kHz -> 48kHz through the polyphase anti-imaging FIR.
int samples_upsampled =
resamp_up_process(&up_rs, input_buffer, samples_read_8k, buffer_upsampled);

// Convert upsampled mono to stereo
for (int i = 0; i < samples_upsampled; i++)
Expand Down Expand Up @@ -808,7 +805,10 @@ void *radio_capture_thread(void *device_ptr)
#endif
ch_layout = capture_input_channel_layout;

static int resample_remainder = 0; // Track fractional samples for accurate resampling
/* Polyphase anti-aliasing downsampler, stateful across reads. */
resampler_global_init();
resamp_down_t down_rs;
resamp_down_reset(&down_rs);

/* --- Capture rate diagnostics (prints every ~5 seconds) --- */
uint64_t diag_start_ms = audioio_monotonic_ms();
Expand All @@ -835,10 +835,10 @@ void *radio_capture_thread(void *device_ptr)
int frames_read = r / frame_size;
int frames_to_write = frames_read;

// Downsample from 48kHz to 8kHz with decimation
// resample_remainder tracks position in decimation cycle (0 to resample_ratio-1)
// When remainder is 0, we take a sample; otherwise skip
int downsampled_frames = 0;
// Extract one mono int32 sample per 48 kHz frame into the scratch
// buffer, then run the polyphase anti-aliasing downsampler. The old
// path decimated 1-in-6 with NO filter, folding everything above
// 4 kHz into the modem band.
for (int i = 0; i < frames_to_write; i++)
{
int32_t sample;
Expand Down Expand Up @@ -902,16 +902,13 @@ void *radio_capture_thread(void *device_ptr)
}
}

// Take every 6th sample (when remainder == 0)
// Bounds check: ensure we don't overflow buffer_downsampled
if (resample_remainder == 0 && downsampled_frames < (int)SIGNAL_BUFFER_SIZE)
{
buffer_downsampled[downsampled_frames++] = sample;
}

resample_remainder = (resample_remainder + 1) % resample_ratio;
buffer_output[i] = sample; // mono 48 kHz scratch
}

int downsampled_frames =
resamp_down_process(&down_rs, buffer_output, frames_to_write,
buffer_downsampled);

if (downsampled_frames > 0)
{
if (circular_buf_free_size(capture_buffer) >= (size_t)(downsampled_frames * sizeof(int32_t)))
Expand Down
132 changes: 132 additions & 0 deletions audioio/resampler.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Polyphase FIR resampler — see resampler.h.
*
* Prototype filter: windowed-sinc low-pass, fc = 3400 Hz at the 48 kHz rate
* (passband flat past the widest modem waveform ~2.5 kHz, stopband by the
* 8 kHz Nyquist of 4 kHz), Hamming window, RESAMP_NTAPS taps, normalised to
* unity DC gain. The same prototype is the anti-imaging filter on upsample
* and the anti-aliasing filter on downsample.
*
* Copyright (C) 2026 Rhizomatica
* SPDX-License-Identifier: GPL-3.0-or-later
*/

#include "resampler.h"

#include <math.h>
#include <string.h>

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

#define FILT_FS 48000.0
#define FILT_FC 3400.0

/* h_up[p][t] = L * proto[p + L*t] — polyphase subfilter for output phase p.
* h_down[k] = proto[k] — flat prototype for decimation. */
static float h_up[RESAMP_L][RESAMP_TAPS_PER_PHASE];
static float h_down[RESAMP_NTAPS];
static int g_inited;

static inline int32_t clamp_i32(double v)
{
if (v > 2147483647.0) return 2147483647;
if (v < -2147483648.0) return (int32_t)(-2147483648.0);
return (int32_t)v;
}

void resampler_global_init(void)
{
double proto[RESAMP_NTAPS];
double sum = 0.0;
const int N = RESAMP_NTAPS;
const double wc = 2.0 * FILT_FC / FILT_FS; /* normalised cutoff (×Nyquist) */
const double mid = (N - 1) / 2.0;

for (int n = 0; n < N; n++) {
double x = n - mid;
double sinc = (fabs(x) < 1e-9) ? wc
: sin(M_PI * wc * x) / (M_PI * x);
double ham = 0.54 - 0.46 * cos(2.0 * M_PI * n / (N - 1));
proto[n] = sinc * ham;
sum += proto[n];
}
/* Normalise to unity DC gain. */
for (int n = 0; n < N; n++)
proto[n] /= sum;

for (int n = 0; n < N; n++)
h_down[n] = (float)proto[n];

/* Polyphase decomposition for the interpolator. Output sample
* (i*L + p) = L * sum_t proto[p + L*t] * x[i - t]. The L gain
* compensates the zero-stuffing energy loss; fold it into the table. */
for (int p = 0; p < RESAMP_L; p++)
for (int t = 0; t < RESAMP_TAPS_PER_PHASE; t++) {
int idx = p + RESAMP_L * t;
h_up[p][t] = (idx < N) ? (float)(RESAMP_L * proto[idx]) : 0.0f;
}

g_inited = 1;
}

/* ---------------- Upsampler 8k -> 48k ---------------- */

void resamp_up_reset(resamp_up_t *r)
{
memset(r->hist, 0, sizeof(r->hist));
}

int resamp_up_process(resamp_up_t *r, const int32_t *in, int n_in, int32_t *out)
{
if (!g_inited) resampler_global_init();

int o = 0;
for (int i = 0; i < n_in; i++) {
/* Shift newest input into hist[0]. */
for (int t = RESAMP_TAPS_PER_PHASE - 1; t > 0; t--)
r->hist[t] = r->hist[t - 1];
r->hist[0] = in[i];

for (int p = 0; p < RESAMP_L; p++) {
double acc = 0.0;
const float *hp = h_up[p];
for (int t = 0; t < RESAMP_TAPS_PER_PHASE; t++)
acc += (double)hp[t] * (double)r->hist[t];
out[o++] = clamp_i32(acc);
}
}
return o;
}

/* ---------------- Downsampler 48k -> 8k ---------------- */

void resamp_down_reset(resamp_down_t *r)
{
memset(r->hist, 0, sizeof(r->hist));
r->phase = 0;
}

int resamp_down_process(resamp_down_t *r, const int32_t *in, int n_in,
int32_t *out)
{
if (!g_inited) resampler_global_init();

int o = 0;
for (int i = 0; i < n_in; i++) {
/* Shift newest input into hist[0]. */
for (int t = RESAMP_NTAPS - 1; t > 0; t--)
r->hist[t] = r->hist[t - 1];
r->hist[0] = in[i];

if (++r->phase >= RESAMP_L) {
r->phase = 0;
double acc = 0.0;
for (int t = 0; t < RESAMP_NTAPS; t++)
acc += (double)h_down[t] * (double)r->hist[t];
out[o++] = clamp_i32(acc);
}
}
return o;
}
54 changes: 54 additions & 0 deletions audioio/resampler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Polyphase FIR resampler for the 8 kHz modem <-> 48 kHz sound-card paths.
*
* Replaces the old linear-interpolation upsampler (weak anti-imaging) and the
* bare 1-in-6 decimator (no anti-aliasing at all) with a proper linear-phase
* low-pass FIR, decomposed polyphase for efficiency and carried statefully
* across read periods so there are no boundary discontinuities (issue #81).
*
* Fixed for the 8 kHz <-> 48 kHz, L=M=6 case. All sample data is int32;
* coefficients are float, accumulation is double, output is clamped.
*
* Copyright (C) 2026 Rhizomatica
* SPDX-License-Identifier: GPL-3.0-or-later
*/

#ifndef AUDIOIO_RESAMPLER_H
#define AUDIOIO_RESAMPLER_H

#include <stdint.h>

#define RESAMP_L 6 /* 8 kHz <-> 48 kHz ratio */
#define RESAMP_TAPS_PER_PHASE 30
#define RESAMP_NTAPS (RESAMP_L * RESAMP_TAPS_PER_PHASE) /* 180 */

/* Build the shared coefficient tables. Idempotent; call once before use
* (thread-safe to call repeatedly from init paths — it just recomputes). */
void resampler_global_init(void);

/* --- Upsampler: 8 kHz -> 48 kHz (anti-imaging) --- */
typedef struct {
int32_t hist[RESAMP_TAPS_PER_PHASE]; /* last K input samples (8 kHz) */
} resamp_up_t;

void resamp_up_reset(resamp_up_t *r);

/* Produce n_in*6 output samples (48 kHz) from n_in input samples (8 kHz).
* out must hold at least n_in*RESAMP_L int32 samples. Returns the count. */
int resamp_up_process(resamp_up_t *r, const int32_t *in, int n_in,
int32_t *out);

/* --- Downsampler: 48 kHz -> 8 kHz (anti-aliasing) --- */
typedef struct {
int32_t hist[RESAMP_NTAPS]; /* last N input samples (48 kHz) */
int phase; /* 0..L-1 decimation phase */
} resamp_down_t;

void resamp_down_reset(resamp_down_t *r);

/* Consume n_in input samples (48 kHz), emit the decimated outputs (8 kHz) to
* out (must hold at least n_in/6 + 1 samples). Returns the number emitted. */
int resamp_down_process(resamp_down_t *r, const int32_t *in, int n_in,
int32_t *out);

#endif /* AUDIOIO_RESAMPLER_H */
6 changes: 5 additions & 1 deletion tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ ARQ_STUBS = datalink_arq/arq_test_stubs.c

# Test executables
TEST_BINS = test_ring_buffer test_arq_protocol test_arq_timing test_arq_fsm \
test_tcp_interfaces
test_tcp_interfaces test_resampler

.PHONY: all test clean

Expand All @@ -58,6 +58,10 @@ test: $(TEST_BINS)
test_ring_buffer: common/test_ring_buffer.c $(UNITY_SRC) ../common/ring_buffer_posix.c ../common/os_interop.c ../common/shm_posix.c
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

# Audio resampler test (offline, no sound card)
test_resampler: audioio/test_resampler.c $(UNITY_SRC) ../audioio/resampler.c
$(CC) $(CFLAGS) -I../audioio -o $@ $^ $(LDFLAGS) -lm

# ARQ protocol tests (frame encode/decode, utilities)
test_arq_protocol: datalink_arq/test_arq_protocol.c $(UNITY_SRC) $(ARQ_STUBS) \
../datalink_arq/arq_protocol.c ../datalink_arq/arith.c
Expand Down
Loading
Loading