FreeDATA/tests/test_rx_audio_callback.py

169 lines
7.6 KiB
Python

"""Tests for the real-time-safe RX audio callback / worker split.
modem.RF.sd_input_audio_callback no longer runs the RX DSP inline; it copies the
captured block onto rx_audio_in_queue and returns, and rx_audio_processing_worker
drains that queue and runs the DSP (resample 48->8 kHz, FFT, demod-buffer push).
The stream is opened with blocksize=0, so the captured block size is whatever
PortAudio has available: device specific, variable, and not a multiple of
anything. rx_audio_processing_worker therefore re-blocks the captured stream to
RX_DSP_BLOCK_48K before any DSP runs on it.
These tests exercise that split directly with synthetic blocks, so they need no
audio hardware (CI has none). They cover:
- the worker actually performs the relocated DSP on an enqueued block,
- odd capture sizes are re-blocked rather than passed to the DSP short, and
- the callback drops (and counts) instead of blocking when the queue is full,
which is the real-time-safety property the change exists to provide.
"""
import threading
import time
import unittest
import numpy as np
from freedata_server.context import AppContext
from freedata_server import modem, codec2
CONFIG = "freedata_server/config.ini.example"
# A capture size PortAudio really does hand us, and deliberately NOT a multiple of
# codec2's FDMDV_OS_48 (6) -- 512 % 6 == 2. Passing this straight to
# resample48_to_8 trips its "multiple of 6" assertion, which is what made the RX
# chain deaf (every block raising AssertionError) once blocksize=0 was used.
CAPTURE_FRAMES = 512
def _rf():
"""A real RF wired to a real AppContext, without opening audio devices.
start_modem() would normally create the resampler (and, in TESTMODE, start
the demodulator decode threads); we only need the resampler here, so we set
it directly and leave the demod buffers as None -- the worker's buffer-push
is guarded by `if audiobuffer` and is intentionally not under test.
"""
ctx = AppContext(CONFIG)
ctx.TESTMODE = True
rf = modem.RF(ctx)
rf.resampler = codec2.resampler()
return rf
def _block(frames=CAPTURE_FRAMES):
# sounddevice delivers indata as shape (frames, channels); int16 mono here.
return (np.random.randn(frames, 1) * 3000).astype(np.int16)
class TestRxAudioCallbackWorkerSplit(unittest.TestCase):
def test_worker_processes_enqueued_blocks(self):
"""Blocks handed to the callback are drained and DSP'd by the worker."""
rf = _rf()
rf.rx_audio_worker_running = True
worker = threading.Thread(target=rf.rx_audio_processing_worker, daemon=True)
worker.start()
try:
# status=None -> the block is enqueued (a truthy status is an
# over/underflow and is dropped by the callback, unchanged by this PR).
# Feed enough captured blocks to complete at least one DSP block.
for _ in range(rf.RX_DSP_BLOCK_48K // CAPTURE_FRAMES + 1):
rf.sd_input_audio_callback(_block(), CAPTURE_FRAMES, None, None)
# The worker resamples to 8 kHz and feeds enqueue_streaming_audio_chunks,
# which lands on ctx.audio_rx_queue -- our deterministic "DSP ran" signal.
deadline = time.time() + 5
while rf.ctx.audio_rx_queue.qsize() == 0 and time.time() < deadline:
time.sleep(0.02)
self.assertGreater(rf.ctx.audio_rx_queue.qsize(), 0, "worker did not process the enqueued RX audio block")
self.assertTrue(rf.rx_audio_in_queue.empty(), "worker should have drained the input queue")
self.assertEqual(rf.rx_audio_dropped_blocks, 0, "no block should be dropped under normal operation")
finally:
rf.rx_audio_worker_running = False
rf.rx_audio_in_queue.put_nowait(None) # release the worker's get()
worker.join(timeout=2)
def test_callback_drops_and_does_not_block_when_queue_full(self):
"""With the worker stalled and the queue full, the callback drops the
block (counted) and returns immediately -- it must never block the
real-time audio thread."""
rf = _rf() # worker intentionally NOT started -> queue never drains
for _ in range(rf.rx_audio_in_queue.maxsize):
rf.rx_audio_in_queue.put_nowait(object())
self.assertTrue(rf.rx_audio_in_queue.full())
t0 = time.perf_counter()
rf.sd_input_audio_callback(_block(), CAPTURE_FRAMES, None, None)
elapsed = time.perf_counter() - t0
self.assertEqual(rf.rx_audio_dropped_blocks, 1, "a full queue must drop the block and count it")
self.assertLess(elapsed, 0.05, "callback must not block on a full queue (real-time safety)")
class TestRxAudioReblocking(unittest.TestCase):
"""The capture block size must never reach the DSP chain.
blocksize=0 means PortAudio picks the size, and real devices hand back sizes
that are not multiples of codec2's FDMDV_OS_48 (6). resample48_to_8 asserts on
those, so process_rx_audio_block must accumulate instead of resampling short.
These call process_rx_audio_block directly (no worker thread) so a failure is
a raised exception rather than a swallowed, logged one.
"""
def test_odd_capture_size_does_not_reach_the_resampler(self):
"""A 512-frame capture (512 % 6 == 2) must not raise AssertionError."""
rf = _rf()
# One short block: not enough for a DSP block, so it is carried, not resampled.
rf.process_rx_audio_block(_block(CAPTURE_FRAMES))
self.assertEqual(len(rf.rx_audio_carry_48k), CAPTURE_FRAMES)
self.assertEqual(rf.ctx.audio_rx_queue.qsize(), 0, "a partial DSP block must not be processed short")
def test_carry_reassembles_whole_dsp_blocks(self):
"""Odd captures are accumulated into exact RX_DSP_BLOCK_48K blocks."""
rf = _rf()
processed = []
rf.run_rx_audio_dsp = lambda audio_48k: processed.append(len(audio_48k))
# A spread of sizes a real device might deliver, none a multiple of 6.
sizes = [512, 1024, 441, 512, 2048, 1024, 512, 940, 512, 1024]
for size in sizes:
rf.process_rx_audio_block(_block(size))
total = sum(sizes)
self.assertEqual(
processed,
[rf.RX_DSP_BLOCK_48K] * (total // rf.RX_DSP_BLOCK_48K),
"every DSP invocation must get exactly one whole block",
)
self.assertEqual(len(rf.rx_audio_carry_48k), total % rf.RX_DSP_BLOCK_48K, "remainder must be carried over")
def test_reblocking_preserves_the_sample_stream(self):
"""No sample is dropped, duplicated or reordered by the re-blocking.
The resampler's filter memory spans blocks, so the stream it sees has to be
the captured stream exactly.
"""
rf = _rf()
seen = []
rf.run_rx_audio_dsp = lambda audio_48k: seen.append(np.array(audio_48k))
sizes = [700, 1300, 512, 4800, 441]
captured = [np.arange(s, dtype=np.int16).reshape(-1, 1) for s in sizes]
for block in captured:
rf.process_rx_audio_block(block)
expected = np.concatenate([b.reshape(-1) for b in captured])
got = np.concatenate(seen + [rf.rx_audio_carry_48k])
np.testing.assert_array_equal(got, expected)
def test_real_resampler_accepts_every_reblocked_block(self):
"""End to end with the real codec2 resampler: odd captures, no assertion."""
rf = _rf()
for size in (512, 1024, 441, 2048, 512, 1024, 512, 4800):
rf.process_rx_audio_block(_block(size)) # raises AssertionError if short
self.assertGreater(rf.ctx.audio_rx_queue.qsize(), 0, "DSP should have run on the reassembled blocks")
if __name__ == "__main__":
unittest.main()