mirror of https://github.com/DJ2LS/FreeDATA.git
169 lines
7.6 KiB
Python
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()
|