Amarisoft

SoapySDR

The purpose of this tutorial is to show how to integrate an Amarisoft SDR card into SoapySDR so that any SoapySDR application can read IQ data from the Amarisoft SDR card and transmit through it. There is no built in SoapySDR support module for the Amarisoft SDR card. You need to build your own support module and install it into the SoapySDR module search path. This requires the Amarisoft SDR driver and the SoapySDR development package. Once the module is installed, every SoapySDR client on the machine — SoapySDRUtil, GNU Radio Soapy blocks, GQRX, CubicSDR or your own C++ program — can drive the Amarisoft card without knowing anything about the Amarisoft API.

Table of Contents

Introduction

Software Defined Radio (SDR) technologies have revolutionized wireless communication by enabling flexible, programmable radio systems that can be configured for a variety of standards and use cases. Amarisoft SDR cards represent a high-performance, programmable radio hardware platform widely adopted in research, development, and prototyping environments for cellular and wireless projects. SoapySDR is an open-source, vendor-neutral SDR abstraction library that provides a unified API for SDR hardware, allowing developers to write applications that can run on multiple radio backends. By integrating an Amarisoft SDR card into the SoapySDR ecosystem, users benefit from platform-agnostic access to advanced radio hardware, enabling seamless interoperability with a wide range of SDR applications such as GNU Radio, GQRX, CubicSDR, and custom C++ programs. This integration is particularly significant because there is no native SoapySDR support for Amarisoft SDR cards, necessitating the development and installation of a custom support module. This process involves leveraging the Amarisoft SDR driver and the SoapySDR development package to bridge the proprietary hardware API with the open-source abstraction layer. The resulting architecture allows any SoapySDR-compatible client application to transparently access and control the Amarisoft card, streamlining development workflows and enhancing the flexibility of wireless experimentation environments. This tutorial provides a comprehensive guide to building, installing, and validating such SoapySDR support, empowering users to unlock the full potential of their Amarisoft SDR hardware within the broader SDR software ecosystem.

Summary of the Tutorial

This tutorial describes how to build, install and validate a SoapySDR support module for the Amarisoft SDR card, so that any SoapySDR application can drive the hardware without containing a single line of Amarisoft specific code. The emphasis throughout is on verifying the result with tools that know nothing about Amarisoft, which is what proves the module implements the standard interface rather than merely working with its own test program.

The approach throughout is to prove the integration from the outside. Every verification step uses either the stock SoapySDR tooling or a program that links against SoapySDR alone, so a passing result says the module behaves like an ordinary SoapySDR device rather than merely cooperating with code that knows its internals.

If you are a human

This tutorial is relatively lengthy and covers many technical details, so you may find it difficult or time-consuming to read the entire page from beginning to end on your own. If that is the case, you can use an AI chatbot to help you understand the contents more efficiently. Simply provide the URL of this tutorial to a chatbot of your choice, such as ChatGPT, Claude, or Grok, and ask it to summarize the tutorial, explain a particular section, or answer questions about topics that are not clear to you. You can also use the chatbot interactively and learn the material through a question-and-answer process instead of reading everything sequentially.

AI coding agents can also be useful for the practical parts of this tutorial. For example, if you want to install or uninstall the soapy_project package, you can ask a coding agent such as Claude Code or Codex to perform the procedure for you by referring to the section titled "Installing/Uninstalling soapy_project package". The coding agent can examine the instructions, execute the necessary commands in your development environment, and help diagnose any errors that occur during the installation process.

In this way, you do not necessarily have to follow every step of this lengthy tutorial manually. You can use the tutorial as a reference source and let an AI chatbot or coding agent help you navigate, understand, and apply the parts that are relevant to your task.

What is the final goal ?

The final goal in this tutorial is to make the Amarisoft SDR card usable from any SoapySDR application without that application containing a single line of Amarisoft specific code. This means the SoapySDR runtime should discover an 'amarisoft' factory, enumerate the SDR cards installed in the machine, open one of them, report its capabilities accurately, stream IQ samples in both directions at the full configured sample rate, and expose the control surface (gain, antenna, frequency, sample rate, timestamps, sensors and settings) through the standard SoapySDR calls. In this example the final verification is that the stock 'SoapySDRUtil' tool, which is part of the SoapySDR package and has no knowledge of Amarisoft hardware, can probe the card and print a complete capability report, and that a streaming client can read samples continuously with no overflows and no gaps in the sample timestamps.

Architecture / Data Flow

The system is composed of four layers. At the lowest level is the SDR hardware itself, the card that captures RF signals from the air and converts them into digital IQ samples. Applications never talk to this hardware directly.

Above the hardware is the Amarisoft driver and library layer. The kernel driver ('sdr.ko') creates the '/dev/sdrN' character devices and provides low level control of the board. The user space library ('libsdr.so') exposes the 'msdr_*' C API on top of that driver, providing functions to open the device, program the RF parameters, start and stop the radio, and read or write IQ sample blocks.

Above that is the SoapySDR support module developed in this tutorial, 'libamarisoftSupport.so'. This is the bridge. It implements the 'SoapySDR::Device' interface by translating each SoapySDR call into the appropriate sequence of 'msdr_*' calls. It also absorbs the differences in behaviour between the two models: SoapySDR expects a device that can be reconfigured while streaming and whose write call blocks for flow control, while 'libsdr' expects a caller that reconfigures only at start time and paces itself from the receive path.

At the top is the SoapySDR runtime and the application. The runtime scans its module directories at start up, loads every support module it finds, and registers the factories they declare. An application then asks the runtime for a device matching 'driver=amarisoft' and receives a 'SoapySDR::Device' pointer. From that point on the application uses only standard SoapySDR calls.

The important property of this design is that the abstraction is one way. The application depends on SoapySDR, and SoapySDR depends on nothing. The support module is the only component that knows both APIs, and it is the only component that has to change if either side changes.

+-------------------------------------------------------------+
|  Application layer                                          |
|  SoapySDRUtil / GNU Radio Soapy blocks / GQRX / custom C++  |
+-------------------------------------------------------------+
                               |
                               |  SoapySDR::Device interface
                               v
+-------------------------------------------------------------+
|  SoapySDR runtime   (libSoapySDR.so)                        |
|  scans modules0.7 directories, registers factories          |
+-------------------------------------------------------------+
                               |
                               |  dlopen
                               v
+-------------------------------------------------------------+
|  libamarisoftSupport.so     <-- built in this tutorial      |
|  Registration.cpp   find() / make() factory                 |
|  Settings.cpp       gain, freq, rate, clocking, sensors     |
|  Streaming.cpp      setupStream / readStream / writeStream  |
+-------------------------------------------------------------+
                               |
                               |  msdr_* C API
                               v
+-------------------------------------------------------------+
|            libsdr.so   +   sdr.ko kernel driver             |
+-------------------------------------------------------------+
                               |
                               |
                               v
+-------------------------------------------------------------+
|        /dev/sdr0  ..  /dev/sdr3      (SDR hardware)         |
+-------------------------------------------------------------+

What Success Looks Like

The clearest single indicator that the integration has worked is the output of 'SoapySDRUtil --info'. Before the module is built, the SoapySDR runtime finds no modules and no factories. After the module is installed, the same command reports the module path and lists 'amarisoft' as an available factory. Nothing in that tool is aware of Amarisoft hardware, so this output comes entirely from the module registering itself correctly through the SoapySDR registry mechanism.

The command takes no arguments and can be run from any directory, since 'SoapySDRUtil' is installed on the PATH by the SoapySDR package. It does not open the radio, so it is safe to run at any time, including while another application is using a board.

[root@UESB-2021102500 ~]# SoapySDRUtil --info

Lib Version: v0.7.1-unknown

API Version: v0.7.1

ABI Version: v0.7

Install root: /usr

Search path:  /usr/lib64/SoapySDR/modules0.7

Search path:  /usr/local/lib64/SoapySDR/modules0.7

Module found: /usr/local/lib64/SoapySDR/modules0.7/libamarisoftSupport.so

Available factories... amarisoft

SoapySDR Install

The most important thing to remember is that you need the SoapySDR development package as well as the runtime, because the support module is compiled against the SoapySDR headers. As with the GNU Radio tutorial, there can be a system dependency such as the type of Linux distribution and the OS version. What has been used in this tutorial is an Amarisoft UEsim PC running Fedora 32 with SoapySDR 0.7.1. You need to install on an Amarisoft Callbox or UEsim because the module is built against the Amarisoft SDR driver and requires the SDR hardware.

NOTE : There are multiple different ways to install the package. In this section I will talk only based on what I have tried. It may or may not go smoothly on your setup. If your distribution does not package SoapySDR, you can build it from source at https://github.com/pothosware/SoapySDR, but the packaged version is much simpler when it is available.

Step 1 - Install SoapySDR and build tools

This step installs the SoapySDR runtime library, the SoapySDR development headers, and the build tools needed to compile the support module. The 'SoapySDR' package provides 'libSoapySDR.so' and the 'SoapySDRUtil' command line tool. The 'SoapySDR-devel' package provides the headers in '/usr/include/SoapySDR' and the CMake configuration files that let your project find the library. The 'cmake' and 'gcc-c++' packages provide the build system and the C++ compiler. On the machine used for this tutorial cmake and gcc-c++ were already present, so only the two SoapySDR packages were actually downloaded.

sudo dnf install SoapySDR SoapySDR-devel cmake gcc-c++

The transaction installs the two SoapySDR packages as shown below. Note the version, 0.7.1, because the module search directory name and the ABI version are derived from it. A module built against one SoapySDR ABI will not be loaded by a runtime with a different ABI.

Installing:

 SoapySDR           x86_64      0.7.1-5.fc32      fedora     159 k

 SoapySDR-devel     x86_64      0.7.1-5.fc32      fedora      34 k

 

Installed:

  SoapySDR-0.7.1-5.fc32.x86_64    SoapySDR-devel-0.7.1-5.fc32.x86_64

Complete!

NOTE : During the install you may see 'ldconfig' warnings such as '/lib64/libcrypto.so.1.1 is not a symbolic link'. These come from unrelated packages on the Amarisoft image and do not affect SoapySDR.

Step 2 - Check the baseline installation

Before writing any code it is worth confirming what the freshly installed SoapySDR can and cannot do. The command 'SoapySDRUtil --info' prints the library version, the module search paths, the modules found and the factories registered. Running it immediately after installation gives the expected empty starting state.

SoapySDRUtil --info

The output confirms three important facts. First, the API and ABI versions are 0.7.1 and 0.7, which is what the module must be built against. Second, there are two module search paths, and the '/usr/local' one does not exist yet, which is where a locally built module will be installed. Third, and most importantly, the runtime reports 'No modules found' and 'No factories found'. This is the correct baseline: SoapySDR is working but has no hardware support at all yet. The converters listed at the end are the built in sample format conversions, which are always present.

######################################################

##     Soapy SDR -- the SDR abstraction library     ##

######################################################

 

Lib Version: v0.7.1-unknown

API Version: v0.7.1

ABI Version: v0.7

Install root: /usr

Search path:  /usr/lib64/SoapySDR/modules0.7

Search path:  /usr/local/lib64/SoapySDR/modules0.7 (missing)

No modules found!

Available factories... No factories found!

Available converters...

 -  CF32 -> [CF32, CS16, CS8, CU16, CU8]

 -  CS16 -> [CF32, CS16, CS8, CU16, CU8]

 -   CS8 -> [CF32, CS16, CS8, CU16, CU8]

   (remaining converters omitted)

Step 3 - Locate the development headers

The 'SoapySDR-devel' package installs its headers into '/usr/include/SoapySDR'. Listing that directory confirms the development package is present and shows the pieces of the API that the module will use. 'Device.hpp' is the interface a support module implements. 'Registry.hpp' provides the mechanism by which a module advertises its factory to the runtime. 'Formats.hpp' defines the sample format identifiers such as 'CF32' and 'CS16'. 'Types.hpp' defines the helper types 'Kwargs', 'Range' and 'ArgInfo'. 'Time.hpp' provides the conversions between sample counts and nanoseconds, and 'Logger.hpp' provides the logging functions used to report progress and warnings back to the host application.

ls -l /usr/include/SoapySDR

Config.h           Constants.h       ConverterPrimitives.hpp

Config.hpp         ConverterRegistry.hpp  Device.h

Device.hpp         Errors.h           Errors.hpp

Formats.h          Formats.hpp       Logger.h

Logger.hpp         Modules.h          Modules.hpp

Registry.hpp       Time.h             Time.hpp

Types.h            Types.hpp          Version.h   Version.hpp

Step 4 - Understand the interface to implement

A SoapySDR support module is a subclass of 'SoapySDR::Device' that overrides the virtual methods relevant to the hardware. The base class provides a default implementation for every method, so a module only implements what the hardware actually supports and leaves the rest alone. Listing the virtual methods in 'Device.hpp' is therefore the most direct way to see the scope of the work.

grep -n "virtual" /usr/include/SoapySDR/Device.hpp

The result is a long list, but it groups into a small number of families. Understanding these groups makes the implementation much easier to organise, and it is the reason the module in this tutorial is split into three source files rather than one.

Identification   getDriverKey, getHardwareKey, getHardwareInfo

Channels         getNumChannels, getFullDuplex, getChannelInfo

Stream           setupStream, closeStream, getStreamMTU,

                 activateStream, deactivateStream,

                 readStream, writeStream, readStreamStatus

Antenna          listAntennas, setAntenna, getAntenna

Gain              listGains, setGain, getGain, getGainRange,

                 hasGainMode, setGainMode, getGainMode  (AGC)

Frequency        setFrequency, getFrequency, getFrequencyRange

Rate / bandwidth setSampleRate, getSampleRate, listSampleRates,

                 setBandwidth, getBandwidth, getBandwidthRange

Clocking / time  listClockSources, setClockSource, listTimeSources,

                 hasHardwareTime, getHardwareTime

Sensors / settings listSensors, readSensor, getSettingInfo,

                 writeSetting, readSetting

Not implemented  registers, GPIO, I2C, SPI, UART  (no libsdr equivalent)

NOTE : The register, GPIO, I2C, SPI and UART families exist in the SoapySDR interface because some hardware exposes them. The Amarisoft 'libsdr' API has no equivalent, so those methods are deliberately left with their default implementations, which report that the feature is unsupported.

Building the Amarisoft Support Module

With SoapySDR installed and its interface understood, the next stage is to build the support module itself. The module is an ordinary CMake project that produces one shared library. It needs the SoapySDR headers found in the previous step, and it needs the Amarisoft SDK: the header 'libsdr.h', the kernel header 'flags.h' that 'libsdr.h' includes, and the shared library 'libsdr.so'. On a standard Amarisoft installation these are found under the 'trx_sdr' release directory, which is normally symlinked as '/root/trx_sdr'.

Project layout

The project is placed in a working directory alongside the other Amarisoft projects. The implementation is split by responsibility. 'Registration.cpp' contains the factory functions that enumerate and instantiate devices, and the static registry object that advertises them to SoapySDR. 'Settings.cpp' contains the control plane: construction, identification, gain, frequency, sample rate, bandwidth, clocking, sensors and settings. 'Streaming.cpp' contains the data plane: stream setup, activation and the read and write paths. 'SoapyAmarisoft.hpp' declares the device class, the stream handle and the measured hardware constants.

/root/soapy_project/SoapyAmarisoft/

+-- CMakeLists.txt            build definition

+-- SoapyAmarisoft.hpp        device class, stream handle, HW constants

+-- Registration.cpp          find() / make() factory registration

+-- Settings.cpp              control plane

+-- Streaming.cpp             data plane

+-- build.sh                  configure, build and install in one step

+-- run_tests.sh              full verification suite

+-- README.md                 design notes and measured hardware behaviour

+-- tests/                    command line test tool

+-- build/                    created by cmake

How those files relate to one another is worth a picture, because two of the relationships are easy to miss. 'SoapyAmarisoft.hpp' is the only file that includes 'libsdr.h': the three translation units below it pick up the Amarisoft API through that header rather than reaching for the SDK themselves, so there is exactly one place to change if the SDK moves. And the test tool does not include the module's header at all - it is compiled against SoapySDR alone, which is what makes it a fair test rather than a private back channel to the implementation.

SoapySDR headers Device.hpp, Logger, Types libsdr.h + flags.h Amarisoft SDK, not redistributable SoapyAmarisoft.hpp device class, stream handle, measured HW constants Registration.cpp find() / make() + Registry Settings.cpp control plane + Formats, Time Streaming.cpp data plane + Formats CMakeLists.txt SOAPY_SDR_MODULE_UTIL libamarisoftSupport.so the module libsdr.so linked, via RPATH SoapySDR runtime scans modules0.7, dlopen tests/soapy_amarisoft_test.cpp includes SoapySDR only - never SoapyAmarisoft.hpp the only file that includes libsdr.h; the three units below get it from here installed into the module search path same rule as hellosoapy

The CMake definition

Two things in the build definition deserve attention. The first is the 'SOAPY_SDR_MODULE_UTIL' macro, which is provided by the SoapySDR CMake configuration installed with 'SoapySDR-devel'. This macro creates a shared module with the correct name, the correct output directory and the correct install destination, so the module lands in the SoapySDR search path automatically.

The second is the handling of 'libsdr'. Its header lives in the 'api' subdirectory of the trx_sdr release, but that header includes 'flags.h' which lives in the 'kernel' subdirectory, so both directories must be on the include path. 'libsdr.so' also has undefined references to 'dlopen', 'pthread_create', 'pow' and similar, so 'dl', 'pthread' and 'm' must be linked explicitly. Finally, 'libsdr.so' is not in any directory the dynamic loader searches by default, so the module records its location as an RPATH. Without that, the module would build successfully but fail to load at run time, and SoapySDR would silently skip it.

find_package(SoapySDR "0.7" REQUIRED)

 

set(TRX_SDR_ROOT "/root/trx_sdr" CACHE PATH "Amarisoft trx_sdr root")

find_path(LIBSDR_INCLUDE_DIR        NAMES libsdr.h HINTS "${TRX_SDR_ROOT}/api")

find_path(LIBSDR_FLAGS_INCLUDE_DIR NAMES flags.h  HINTS "${TRX_SDR_ROOT}/kernel")

find_library(LIBSDR_LIBRARY         NAMES sdr      HINTS "${TRX_SDR_ROOT}/api")

 

SOAPY_SDR_MODULE_UTIL(

    TARGET     amarisoftSupport

    SOURCES    Registration.cpp Settings.cpp Streaming.cpp

    LIBRARIES ${LIBSDR_LIBRARY} dl pthread m

)

 

set_target_properties(amarisoftSupport PROPERTIES

    INSTALL_RPATH "${LIBSDR_LIBRARY_DIR}"  BUILD_WITH_INSTALL_RPATH TRUE)

The registration itself is only a few lines. A static 'SoapySDR::Registry' object is constructed at module load time. Its constructor tells the SoapySDR runtime the driver name, the function used to enumerate devices, the function used to create one, and the ABI version the module was built against. The ABI check is what prevents a module built for a different SoapySDR release from being loaded.

static SoapySDR::Registry registerAmarisoft(

    "amarisoft", &findAmarisoft, &makeAmarisoft, SOAPY_SDR_ABI_VERSION);

NOTE : Device enumeration deliberately does not open the boards. Opening an Amarisoft SDR is exclusive, so probing every node would make a device disappear from the listing whenever lteue, lteenb or another SoapySDR client already holds it. Listing the '/dev/sdrN' nodes is enough to describe what is available.

Build and install

The build follows the usual CMake sequence. The 'make install' step copies the module into '/usr/local/lib64/SoapySDR/modules0.7', which is the second of the two search paths reported earlier by 'SoapySDRUtil --info'. No further registration step is needed: SoapySDR discovers modules by scanning those directories at start up.

cd /root/soapy_project/SoapyAmarisoft

mkdir -p build && cd build

cmake ..

make -j$(nproc)

sudo make install

The same sequence is wrapped in a helper script, which also runs the module load check at the end. Use 'TRX_SDR_ROOT' to point at a specific trx_sdr release if you do not use the '/root/trx_sdr' symlink.

./build.sh

TRX_SDR_ROOT=/root/trx_sdr-linux-2026-08-18 ./build.sh

NOTE : After any source change, remember that applications load the installed module from '/usr/local/lib64/SoapySDR/modules0.7', not the one in the build directory. Rebuilding without reinstalling is a common way to spend time debugging a fix that is not actually loaded.

Amarisoft libsdr Behaviour the Module Handles

The Amarisoft 'libsdr' API and the SoapySDR device model make different assumptions. SoapySDR assumes a device that can be retuned while streaming, whose write call blocks for flow control, and whose calls report errors by return value. The Amarisoft library was designed for LTE and 5G stacks that configure everything at start time and pace themselves from the receive path. The differences below were established by probing an AD9361 based board (hardware ID 0x4b01) with small standalone C programs before the module was written, and each one shapes part of the implementation. They are documented here because anyone extending the module, or writing a different one against the same API, will run into them.

An out of range frequency terminates the process

This is the most important behaviour to know about. When the AD9361 front end is asked for a frequency it cannot produce, 'libsdr' does not return an error code. It prints a message and calls 'exit()', which terminates the calling application. A generic SoapySDR client that sweeps a frequency range would therefore vanish without warning rather than receive an exception.

AD9361 : Invalid RF frequency: 100 MHz

ad9361_init : AD9361 initialization error

*** Can't initialize phy ***

                         <-- the whole application exits here

That listing is what libsdr does on its own. The module intercepts the request first, so the same frequency asked for through SoapySDR produces an ordinary error and leaves the program running. This is worth reproducing on a new installation, because it is the one failure mode that costs you the whole process if the guard is ever bypassed.

[root@UESB-2021102500 ~]# cd /root/soapy_project/SoapyAmarisoft/build

[root@UESB-2021102500 build]# ./tests/soapy_amarisoft_test rx --device /dev/sdr2 --freq 100e6 --seconds 1

ERROR: SoapyAmarisoft: requested frequency 100.000000 MHz is outside the

       tunable range 300.000000 - 6000.000000 MHz

 

(the shell prompt returns: the process was not killed)

The window libsdr accepts on the board used here, found by bisection, is 300 MHz to 6 GHz, and anything outside it is refused before it reaches the library. Accepted is not the same as specified: Amarisoft documents RF coverage as 500 MHz to 6 GHz in trx_sdr.doc and 400 MHz to 6 GHz in trx_sdr100.doc. The guard sits at the wider measured limit deliberately, because its purpose is to keep the process alive rather than to enforce the datasheet; tuning below the published figure still works but puts the board outside its specified range. The module therefore range checks every tune request before it reaches 'libsdr' and raises an ordinary C++ exception instead, which SoapySDR delivers to the application in the normal way. The limits are constants in 'SoapyAmarisoft.hpp' and should be revisited for a different RF front end.

There is no live retune

The obvious way to change frequency on a running device is 'msdr_set_freq_fast'. On the AD9361 front end this function returns -1 for every combination of arguments, so there is no working live retune path. Changing frequency, sample rate, bandwidth, antenna or clocking therefore requires a full stop, reconfigure and restart cycle, which the module performs automatically. The cost was measured at roughly 130 ms at 30.72 Msps, 170 ms at 23.04 Msps, and up to 1.5 s at 1.92 Msps.

NOTE : A restart resets the hardware sample counter, so timestamps jump backwards across a retune. Treat 'getHardwareTime()' as monotonic only within a single activation. If you prefer to batch configuration changes instead, set the 'auto_restart' setting to false and the changes will be deferred until the next 'activateStream()'.

msdr_read works in whole DMA hyperframes

'msdr_read' rejects any request smaller than one DMA hyperframe, reporting 'Sample count N should be at least M', and it never returns more than one hyperframe no matter how large a count is requested. The hyperframe size is the sample rate divided by 15000 for the 3GPP rate ladder, and the sample rate divided by 10000 for other rates. This is why the stream MTU is what it is, and why 'readStream' requests exactly one hyperframe and stages any remainder the caller did not have room for.

sample rate      hyperframe     stream MTU

 1.92 Msps       128 samples    128

23.04 Msps      1536 samples   1536

30.72 Msps      2048 samples   2048

61.44 Msps      4096 samples   4096

25.00 Msps      2500 samples   2500  (not on the 1.92 MHz ladder)

msdr_write does not block

In the Amarisoft applications, transmit pacing comes from the matching 'msdr_read' call: the stack reads a block of receive samples, which blocks until they are available, and then writes the corresponding transmit block. A SoapySDR client may have no receive stream at all. Left unpaced, a transmit loop accepted two seconds of samples in 0.75 seconds and ran arbitrarily far ahead of the hardware clock.

The module therefore throttles 'writeStream' itself, blocking until the transmit cursor is within 'tx_max_lead_us' (20 ms by default) of the hardware sample counter. One detail matters here: reading that counter costs about as much as a whole hyperframe of samples, so querying it on every write consumed roughly 20 percent of the available throughput and produced continuous underflows. The module samples the counter every 100 ms and interpolates with the monotonic clock in between, which paces transmit at exactly the configured rate with no underflows. Setting 'tx_max_lead_us' to 0 disables the throttle for applications that pace transmit from their own receive loop.

Calls that are only valid once the device is started

Several 'libsdr' functions are not usable before 'msdr_start'. 'msdr_get_rx_gain_min_max', 'msdr_get_tx_gain_min_max' and 'msdr_dump_info' all segfault if they are called first, because each walks state the driver has not set up yet. Since the gain limits and the board identity are exactly what a client wants from a capability probe, the module brings the board up briefly on first use, collects both, and stops it again. This behaviour can be disabled with the 'probe_info=false' device argument.

Three smaller points complete the picture. 'libsdr' rejects a configuration with no receiver, so a transmit-only application still gets one receive channel programmed whose samples are never read. 'msdr_get_agc' does not report the AGC flag back, so the requested mode is tracked in software and returned by 'getGainMode'. And 'msdr_get_tai_time' hangs on this board, so it is not used.

Verification

The verification steps below move from the cheapest check to the most demanding. The first three use only the stock 'SoapySDRUtil' tool, which is the strongest evidence that the module implements the standard interface correctly rather than merely working with its own test program.

Before starting, note which SDR devices are free. Opening an Amarisoft SDR is exclusive, so a board already in use by lteue or lteenb cannot be opened by SoapySDR. The following command lists the device nodes currently held open by any process.

ls -l /proc/[0-9]*/fd/* 2>/dev/null | grep -o &apos;/dev/sdr[0-9]*&apos; | sort -u

The command 'SoapySDRUtil --info' confirms that the runtime found and loaded the module and registered the factory. This is the single most useful check: if the module is missing from this output, nothing else will work, and the usual causes are that 'make install' was not run, or that 'libsdr.so' could not be resolved at load time.

SoapySDRUtil --info

The command 'SoapySDRUtil --find' exercises the enumeration path. It should list one entry per '/dev/sdrN' node present on the machine, each with the driver name, the device path and a human readable label.

SoapySDRUtil --find="driver=amarisoft"

Found device 0

  device = /dev/sdr0

  driver = amarisoft

  label  = Amarisoft SDR /dev/sdr0

 

Found device 1  .. 2 .. 3  (one per /dev/sdrN node)

The command 'SoapySDRUtil --probe' opens a specific board and prints its full capability report. This exercises construction, identification, channel enumeration, gain and frequency ranges, sensors and settings in one step. Choose a device that is not already in use.

SoapySDRUtil --probe="driver=amarisoft,device=/dev/sdr2"

-- Device identification

  driver=amarisoft

  hardware=AD9361

  hardware_id=0x4b01   fpga_revision=2026-06-03  16:51:11

 

-- Peripheral summary

  Channels: 2 Rx, 2 Tx

  Timestamps: YES

  Clock sources: internal, external, external_10mhz

  Sensors: fpga_temp, rx_overflow_count, tx_underflow_count, ...

 

-- RX Channel 0

  Full-duplex: YES   Supports AGC: YES

  Stream formats: CF32, CS16   Native format: CF32 [full-scale=1]

  Antennas: RX, TX_RX

  Full gain range: [3, 71] dB

Running the test suite

The project includes a test tool that drives the module through the public SoapySDR API only, and a script that runs every mode and prints a pass or fail tally. The script picks a '/dev/sdrN' that is not already held by another process, or you can name one explicitly. The transmit tests are skipped unless '--tx' is given, because they key the transmitter.

cd /root/soapy_project/SoapyAmarisoft

./run_tests.sh                 # receive tests, auto-selects a free board

./run_tests.sh /dev/sdr2       # pin a specific board

./run_tests.sh /dev/sdr2 --tx  # include the transmit tests

The individual modes can also be run on their own. The 'info' mode prints the full capability report, 'rx' streams and reports signal statistics, 'retune' exercises the stop and restart path, 'tx' transmits a tone, and 'loopback' transmits and receives at the same time.

cd build

./tests/soapy_amarisoft_test info      --device /dev/sdr2

./tests/soapy_amarisoft_test rx         --device /dev/sdr2 --freq 2140e6 --rate 23.04e6 --seconds 3

./tests/soapy_amarisoft_test rx         --device /dev/sdr2 --channels 0,1

./tests/soapy_amarisoft_test rx         --device /dev/sdr2 --format CS16

./tests/soapy_amarisoft_test retune     --device /dev/sdr2

./tests/soapy_amarisoft_test tx         --device /dev/sdr2 --tx-gain 0

./tests/soapy_amarisoft_test loopback   --device /dev/sdr2 --tx-gain 20 --tone 2e6

NOTE : The 'tx' and 'loopback' modes key the transmitter. Use a cable and an attenuator, or keep '--tx-gain 0', unless you are certain the RF output is safely terminated.

A healthy receive run reports an effective rate matching the configured rate, with no overflows and no discontinuities. The timestamp span should agree with the sample count, which confirms that the sample stream has no hidden gaps.

[root@UESB-2021102500 ~]# cd /root/soapy_project/SoapyAmarisoft/build

[root@UESB-2021102500 build]# ./tests/soapy_amarisoft_test rx --device /dev/sdr2 \

        --freq 2140e6 --rate 23.04e6 --seconds 3

RX configured: 23.04 Msps, 2140 MHz, 40 dB, bw 23.04 MHz

stream MTU      : 1536 samples

 

--- results ---

  samples read    : 69120000 in 45000 reads

  effective rate : 23.0418 Msps (configured 23.04)

  mean power      : -56.4264 dBFS

  timeouts        : 0

  discontinuities: 0

  timestamp span : 2.99993 s across 69120000 samples (23.0405 Msps implied)

  rx overflows    : 0

The loopback mode deserves a word of explanation because a mean power figure alone does not prove that a transmitted signal was received. The mode transmits a tone at a chosen offset from the carrier, then mixes each received block down by that same offset and integrates. A tone that genuinely arrives survives the integration whatever its arrival phase, while uncorrelated noise averages away. Two controls confirm the detection is real rather than a fixed spur: moving '--tone' should move the detection with it, and setting '--tx-gain 0' should collapse it to the noise floor.

[root@UESB-2021102500 build]# ./tests/soapy_amarisoft_test loopback \

        --device /dev/sdr2 --tx-gain 20 --tone 2e6

  samples sent    : 46080000

  tx underflows   : 0

  tone at +2 MHz: mean -16.79 dB, peak -13.30 dB relative to total power

  (noise floor for 1536-sample blocks is -31.86 dB)

Measured results

The following results were obtained on '/dev/sdr2' of an Amarisoft UEsim, with the board reporting hardware ID 0x4b01 and an AD9361 front end. They are the reference against which a new installation can be compared.

Test                          Result

--------------------------------------------------------------------

RX CF32 @ 23.04 Msps      23.042 Msps, 0 overflows, 0 discontinuities

RX CS16 @ 23.04 Msps      23.042 Msps, 0 overflows

RX CF32 @ 30.72 Msps      30.722 Msps, 0 overflows

RX 2-channel (0,1)        both channels at full rate, independent data

Retune 2140/2160/2120/    170 ms each, streaming resumed every time,

       751/2140 MHz            received power tracked frequency

TX @ 23.04 Msps           23.12 Msps, 0 underflows, 0 write errors

Full-duplex loopback      tone 15 dB above the noise floor

Frequency guard           100 MHz and 6.5 GHz rejected by exception,

                         process survived

hellosoapy - a Self Contained Test Program

The shell based suite in the previous section drives a separate test tool. It is often more convenient to have a single program that can be dropped onto a machine, compiled and run, and this section presents exactly that. The program is called 'hellosoapy' and it performs the same checks as 'run_tests.sh', reporting a pass or fail tally and returning a process exit status suitable for a smoke test or a CI job.

The important property of this program is what it does not contain. There is no Amarisoft header and no reference to any 'libsdr' symbol anywhere in the source. Every operation goes through the public SoapySDR interface, and the only library it links against is 'libSoapySDR.so'. That is what makes it a meaningful test rather than a private back channel to the hardware: if this program builds and passes, the support module really is behaving like a normal SoapySDR device. Running 'ldd' on the resulting binary confirms it.

[root@UESB-2021102500 ~]# cd /root/soapy_project/hellosoapy

[root@UESB-2021102500 hellosoapy]# ldd build/hellosoapy | grep -i soapy

libSoapySDR.so.0.7 => /lib64/libSoapySDR.so.0.7

Because SoapySDR is the only dependency, the project needs nothing from the support module source tree. It lives beside it purely for convenience.

What it checks

The program runs eleven checks in increasing order of demand. The first two run before any device is opened, because if the module is not loaded there is no point attempting anything else. The transmit checks are skipped unless '--tx' is given, since they key the transmitter.

  #   Test                                 Passes when
 ---  -----------------------------------  ------------------------------------------
  1   Module loaded, factory registered    "amarisoft" appears in the loaded modules
                                           and in the factory registry
  2   Device enumeration                   at least one /dev/sdrN is reported;
                                           boards held by another process are flagged
  3   Device info                          driver key, hardware key, channel counts,
                                           gain and frequency ranges, temperature
  4   RX CF32 at the --rate rate           full rate, no discontinuities
  5   RX CS16                              full rate in the converted format
  6   RX CF32 at a second rate             proves the rate-change path works
  7   RX 2-channel MIMO                    both channels stream together
  8   Retune while streaming               streaming resumes after each of 5 retunes
                                           and getFrequency agrees
  9   TX (gain 0)                          transmit paced to the configured rate,
                                           no underflows                      [--tx]
 10   Full-duplex loopback                 TX and RX stream at once; tone reported
                                                                              [--tx]
 11   Out-of-range frequency guard         100 MHz and 6.5 GHz raise exceptions
                                           and the process survives

NOTE : Check 11 is the one worth understanding. A frequency the AD9361 cannot produce is a fatal error inside 'libsdr', which prints a message and calls 'exit()' on the calling process. The module range checks before the request reaches 'libsdr', so a correct result is an exception followed by a device that still works. If the program terminates without printing the result of check 11, that guard has been bypassed.

Two of the pass criteria are deliberately stricter than "it ran". A receive check requires the achieved rate to be within five percent of the configured rate and zero discontinuities, because counting samples alone would pass a stream that silently dropped data and caught up. The transmit check verifies that transmit is paced to the configured rate, since 'msdr_write' does not block and an unthrottled writer would race ahead of the hardware clock.

Build and run

The only requirement is SoapySDR and its development package, plus the Amarisoft support module installed in a SoapySDR search path. The helper script configures, builds and optionally runs in one step.

cd /root/soapy_project/hellosoapy

./build.sh # build into ./build

./build.sh --run # build, then run the receive checks

./build.sh --run --tx # build, then run everything

With no device argument the program selects a board that no other process is holding open, by scanning '/proc/*/fd' for '/dev/sdr*' symlinks. Opening an Amarisoft SDR is exclusive, so a board already in use by lteue or lteenb cannot be opened; naming a busy device explicitly produces a warning rather than a confusing failure later.

cd build

./hellosoapy # auto-select a free board

./hellosoapy /dev/sdr2 # pin a board

./hellosoapy /dev/sdr2 --tx # include the transmit checks

./hellosoapy /dev/sdr0 --freq 3489.42e6 --rate 46.08e6 --gain 6

The last line is a practical example: it points the program at a live 5G NR band n78 channel at 3489.42 MHz sampled at 46.08 Msps. Two details in it are worth noting. The board is chosen because that is where the RF for the channel is actually cabled, not simply the first free one. The gain is 6 dB rather than the default 40 dB because that input saturates the receiver: above roughly 15 dB of gain the peak level pins at +3.01 dBFS, which is the signature of hard clipping.

--tx also run the transmit checks (these key the TX port)

--seconds S capture duration per streaming check (default 2)

--freq HZ centre frequency (default 2140e6)

--rate HZ sample rate (default 23.04e6)

--gain DB RX gain (default 40)

--tx-gain DB TX gain for the loopback check (default 20)

--tone HZ loopback tone offset (default 2e6)

--antenna NAME RX antenna: RX or TX_RX (the latter for a shared

TDD connector)

--verbose turn on SoapySDR debug logging

NOTE : '--tx' and the loopback check key the transmitter. Use a cable and an attenuator, or leave '--tx' off. The transmit check itself forces gain 0 so it exercises the data path without meaningful emission. Never pass '--tx' on a board connected to a live channel.

Example run

The following is the output of a complete run including the transmit checks, copied straight from the terminal, on an Amarisoft UEsim with the support module installed. The exit status was 0.

[root@UESB-2021102500 ~]# cd /root/soapy_project/hellosoapy/build

[root@UESB-2021102500 build]# ./hellosoapy /dev/sdr2 --tx

hello soapy - Amarisoft SoapySDR module check

############################################################
## 1. Module loaded and factory registered
############################################################
   root path     : /usr
   search path   : /usr/lib64/SoapySDR/modules0.7
   search path   : /usr/local/lib64/SoapySDR/modules0.7
   module        : /usr/local/lib64/SoapySDR/modules0.7/libamarisoftSupport.so
   factory       : amarisoft
   factory       : null
-> PASS: 1. Module loaded and factory registered

############################################################
## 2. Device enumeration
############################################################
   /dev/sdr0
   /dev/sdr1
   /dev/sdr2
   /dev/sdr3
-> PASS: 2. Device enumeration

Using device: /dev/sdr2

############################################################
## 3. Device info
############################################################
   driver        : amarisoft
   hardware      : AD9361
   board_count   : 1
   device        : /dev/sdr2
   dna           : [0x000414402e4a285c]
   fpga_revision : 2026-06-03  16:51:11
   hardware_id   : 0x4b01
   interface     : RF
   open_args     : dev0=/dev/sdr2
   rf_type       : 1
   serial        : ''
   channels      : 2 RX, 2 TX
   RX gain range : 3 .. 71 dB
   RX freq range : 300 .. 6000 MHz
   formats       : CF32 CS16 
   fpga temp     : 59.060083 C
-> PASS: 3. Device info

############################################################
## 4. RX CF32 @ 23.04 Msps
############################################################
   23.04 Msps, 2140 MHz, CF32, antenna RX, channels 0
   samples read    : 46080000 in 30000 reads
   effective rate  : 23.0421 Msps (configured 23.04)
   mean power      : -56.389 dBFS
   timeouts        : 0
   discontinuities : 0
   timestamp span  : 1.99993 s (23.0408 Msps implied)
   rx overflows    : 0
-> PASS: 4. RX CF32 @ 23.04 Msps

############################################################
## 5. RX CS16
############################################################
   23.04 Msps, 2140 MHz, CS16, antenna RX, channels 0
   samples read    : 46080000 in 30000 reads
   effective rate  : 23.0418 Msps (configured 23.04)
   mean power      : -56.3911 dBFS
   timeouts        : 0
   discontinuities : 0
   timestamp span  : 1.99993 s (23.0408 Msps implied)
   rx overflows    : 0
-> PASS: 5. RX CS16

############################################################
## 6. RX CF32 @ 30.72 Msps
############################################################
   30.72 Msps, 2140 MHz, CF32, antenna RX, channels 0
   samples read    : 61440000 in 30000 reads
   effective rate  : 30.7235 Msps (configured 30.72)
   mean power      : -55.0987 dBFS
   timeouts        : 0
   discontinuities : 0
   timestamp span  : 1.99993 s (30.721 Msps implied)
   rx overflows    : 0
-> PASS: 6. RX CF32 @ 30.72 Msps

############################################################
## 7. RX 2-channel MIMO
############################################################
   23.04 Msps, 2140 MHz, CF32, antenna RX, channels 0 1
   samples read    : 46080000 in 30000 reads
   effective rate  : 23.0436 Msps (configured 23.04)
   mean power      : -56.5784 dBFS
   timeouts        : 0
   discontinuities : 0
   timestamp span  : 1.99993 s (23.0408 Msps implied)
   rx overflows    : 0
-> PASS: 7. RX 2-channel MIMO

############################################################
## 8. Retune while streaming
############################################################
   tuned to 2140     MHz in 0.000145 ms | 4608000   samples | mean -56.3794 dBFS
   tuned to 2160     MHz in 170.498  ms | 4608000   samples | mean -56.8796 dBFS
   tuned to 2120     MHz in 170.516  ms | 4608000   samples | mean -54.3245 dBFS
   tuned to 751      MHz in 170.512  ms | 4608000   samples | mean -60.9325 dBFS
   tuned to 2140     MHz in 170.523  ms | 4608000   samples | mean -56.4068 dBFS
-> PASS: 8. Retune while streaming

############################################################
## 9. TX (gain 0)
############################################################
   23.04 Msps, 2140 MHz, 0 dB
   samples written : 46080000 in 1.98997 s (23.1562 Msps)
   write errors    : 0
   tx underflows   : 0
-> PASS: 9. TX (gain 0)

############################################################
## 10. Full-duplex loopback
############################################################
   RX 2140 MHz, TX 2140 MHz, tone +2 MHz, TX gain 20 dB
   samples read    : 46080000 in 30000 reads
   effective rate  : 23.0382 Msps (configured 23.04)
   mean power      : -56.2464 dBFS
   timeouts        : 0
   discontinuities : 0
   timestamp span  : 1.99993 s (23.0408 Msps implied)
   samples sent    : 46080000
   tx underflows   : 0
   tone at +2 MHz: -16.5526 dB vs -31.8639 dB noise floor
-> PASS: 10. Full-duplex loopback

############################################################
## 11. Out-of-range frequency guard
############################################################
   100 MHz rejected: SoapyAmarisoft: requested frequency 100.000000 MHz is outside the tunable range 300.000000 - 6000.000000 MHz
   6500 MHz rejected: SoapyAmarisoft: requested frequency 6500.000000 MHz is outside the tunable range 300.000000 - 6000.000000 MHz
   device still usable, retuned to 2140 MHz
-> PASS: 11. Out-of-range frequency guard

============================================================
  passed: 11   failed: 0   skipped: 0   device: /dev/sdr2
============================================================

Source code

The complete program follows. It is a single translation unit of roughly nine hundred lines, organised as a small test harness, a set of RAII wrappers that guarantee the device and any streams are released even when a check throws, a few shared helpers for capture and statistics, and then the eleven checks themselves.

The calls that any SoapySDR application must make, in the order it must make them, are highlighted in the listing: open the device, set up a stream, activate it, read from it while checking the return value, then deactivate, close and release. Everything else is this program's own scaffolding.

Two things about those highlights are worth noticing. The calls are not adjacent: the open and the release are paired inside 'DeviceHandle', and the setup, activate, deactivate and close are paired inside 'StreamHandle', so that the card is released even when a check fails part way through. And the catch clause is highlighted alongside them because it is equally mandatory - 'SoapySDR::Device::make' reports failure by throwing, so an application that does not catch will terminate the moment it meets a board that another process already holds.

The return value of 'readStream' is highlighted in two places for the same reason. A return of SOAPY_SDR_TIMEOUT is a normal event rather than an error, and a short read is normal too, because the call returns one DMA hyperframe rather than however many samples were asked for. Treating either as a failure, or assuming the requested count was delivered, is the most common way to corrupt a capture.

The structure of the program is shown below. 'main()' selects a board and then hands each check to 'Harness::run()', which prints the banner, catches anything that escapes and keeps the tally. The checks share four helpers for capture and judgement, and the two resource handles at the bottom are what let a check throw without stranding the hardware.

resource handles and shared state the eleven checks - each returns true or throws main() select a board, run the checks usage() --help busyNodes() scan /proc for holders enumerateDevices() ask the factory Harness::run() banner, verdict, tally testModuleVisible() testEnumerate() testInfo() testRx() testRetune() testTx() ... captureRx() stream for N seconds reportRx() print the summary rateIsHealthy() rate + no gaps makeTone() TX waveform DeviceHandle make / unmake StreamHandle setup / activate / close accumulate() fold a block struct Stats rate, power, gaps every check opens its own device; the handles release it even when a check throws

Following is the entire source code. Try to get the overall structure of the code shown above and then look into the code to find the details. Or just copy the entire code into an AI agent (e.g, Claude Code , Codex etc) and ask the details

//
// hellosoapy - a self contained "hello world" for the Amarisoft SoapySDR module
//
// This is the C++ equivalent of run_tests.sh.  It drives the Amarisoft SDR
// card entirely through the public SoapySDR API: there is no Amarisoft header
// and no libsdr symbol anywhere in this file, which is the whole point of the
// abstraction.  If this program builds and passes, the support module is
// correctly installed and behaving.
//
// Build:   ./build.sh          (or: mkdir build && cd build && cmake .. && make)
// Run:     ./build/hellosoapy                 receive tests, auto-picks a board
//          ./build/hellosoapy /dev/sdr2       pin a specific board
//          ./build/hellosoapy /dev/sdr2 --tx  include the transmit tests
//
// SPDX-License-Identifier: MIT
//

#include <SoapySDR/Device.hpp>
#include <SoapySDR/Errors.hpp>
#include <SoapySDR/Formats.hpp>
#include <SoapySDR/Logger.hpp>
#include <SoapySDR/Modules.hpp>
#include <SoapySDR/Registry.hpp>

#include <algorithm>
#include <chrono>
#include <cmath>
#include <complex>
#include <cstring>
#include <functional>
#include <iomanip>
#include <iostream>
#include <set>
#include <sstream>
#include <string>
#include <vector>

#include <dirent.h>
#include <unistd.h>

namespace {

/***********************************************************************
 * Options
 **********************************************************************/

/*!
 * Everything the command line can influence, in one place.  Defaults are the
 * values used when the program is run with no arguments at all.
 */
struct Options
{
    std::string device;          // empty => auto-select a free board
    bool runTx = false;          // transmit tests key the TX port, so opt-in
    double seconds = 2.0;        // capture duration per streaming test
    double freq = 2140e6;
    double rate = 23.04e6;
    double rxGain = 40.0;
    double txGain = 20.0;        // loopback only; the tx test forces 0
    double toneHz = 2e6;
    std::string antenna;         // empty => leave the module default ("RX")
    bool verbose = false;
};

/***********************************************************************
 * Tiny test harness
 **********************************************************************/

/*!
 * Minimal test runner: prints a banner per test, records the verdict, and
 * keeps the running tally that main() reports at the end.
 */
class Harness
{
public:
    /*!
     * Run one test and record its verdict.
     *
     * A test signals failure by returning false or by throwing.  An escaping
     * exception is caught and counted as a failure, so one broken test can
     * never abort the whole suite.
     *
     * \param name  label shown in the banner and in the verdict line
     * \param body  the test itself
     */
    void run(const std::string &name, const std::function<bool()> &body)
    {
        std::cout << "\n############################################################\n";
        std::cout << "## " << name << "\n";
        std::cout << "############################################################\n";

        bool ok = false;
        try
        {
            ok = body();
        }
        catch (const std::exception &ex)
        {
            std::cout << "   exception: " << ex.what() << "\n";
            ok = false;
        }

        if (ok) { std::cout << "-> PASS: " << name << "\n"; _passed++; }
        else    { std::cout << "-> FAIL: " << name << "\n"; _failed++; }
    }

    /*!
     * Record a test that was deliberately not run, with the reason.  Skipped
     * tests are reported separately so they are never mistaken for passes.
     *
     * \param name  label of the test being skipped
     * \param why   reason shown to the user
     */
    void skip(const std::string &name, const std::string &why)
    {
        std::cout << "\n-- SKIP: " << name << " (" << why << ")\n";
        _skipped++;
    }

    /*! Number of tests that passed so far. */
    int passed() const { return _passed; }

    /*! Number of tests that failed so far; the process exit status derives
     *  from this. */
    int failed() const { return _failed; }

    /*! Number of tests that were skipped rather than run. */
    int skipped() const { return _skipped; }

private:
    int _passed = 0;
    int _failed = 0;
    int _skipped = 0;
};

/***********************************************************************
 * Device selection
 **********************************************************************/

/*!
 * Find the SDR device nodes that some process already has open.
 *
 * Opening an Amarisoft SDR is exclusive, so a board in use by lteue or lteenb
 * cannot be opened here.  Rather than discover that by failing, the fd tables
 * under /proc are scanned for symlinks pointing at /dev/sdr*.  This mirrors
 * what run_tests.sh does.  Processes owned by other users are skipped
 * silently, so the result is a lower bound.
 *
 * \return the set of /dev/sdrN paths that appear to be busy
 */
std::set<std::string> busyNodes()
{
    std::set<std::string> busy;

    DIR *proc = opendir("/proc");
    if (proc == nullptr) return busy;

    struct dirent *pid = nullptr;
    while ((pid = readdir(proc)) != nullptr)
    {
        // Only numeric entries are processes.
        const std::string name(pid->d_name);
        if (name.empty() || name.find_first_not_of("0123456789") != std::string::npos)
            continue;

        const std::string fdDir = "/proc/" + name + "/fd";
        DIR *fds = opendir(fdDir.c_str());
        if (fds == nullptr) continue;   // not ours, or already gone

        struct dirent *fd = nullptr;
        while ((fd = readdir(fds)) != nullptr)
        {
            char target[256];
            const std::string link = fdDir + "/" + fd->d_name;
            const ssize_t n = readlink(link.c_str(), target, sizeof(target) - 1);
            if (n <= 0) continue;
            target[n] = '\0';

            const std::string t(target);
            if (t.compare(0, 8, "/dev/sdr") == 0) busy.insert(t);
        }
        closedir(fds);
    }
    closedir(proc);
    return busy;
}

/*!
 * Ask the module which devices exist.
 *
 * \return the /dev/sdrN paths reported by the amarisoft factory, in
 *         enumeration order
 */
std::vector<std::string> enumerateDevices()
{
    SoapySDR::Kwargs filter;
    filter["driver"] = "amarisoft";

    std::vector<std::string> nodes;
    for (const auto &d : SoapySDR::Device::enumerate(filter))
    {
        const auto it = d.find("device");
        if (it != d.end()) nodes.push_back(it->second);
    }
    return nodes;
}

/*!
 * Open the device named by the options, or the first one found if none was
 * named.  The caller owns the result and must release it with
 * SoapySDR::Device::unmake; DeviceHandle exists to do exactly that.
 *
 * \param o  parsed command line options
 * \return   an open device; throws if it cannot be opened
 */
SoapySDR::Device *openDevice(const Options &o)
{
    SoapySDR::Kwargs args;
    args["driver"] = "amarisoft";
    if (!o.device.empty()) args["device"] = o.device;
    return SoapySDR::Device::make(args);
}

/*!
 * RAII holder so a device is always released, including when a test throws.
 * SoapySDR::Device::unmake is the required counterpart to make().
 */
class DeviceHandle
{
public:
    /*! Open the device described by \p o, or throw if it cannot be opened. */
    explicit DeviceHandle(const Options &o) : _dev(openDevice(o)) {}

    /*! Release the device.  Runs on normal exit and while unwinding alike. */
    ~DeviceHandle() { if (_dev != nullptr) SoapySDR::Device::unmake(_dev); }

    /*! Non-copyable: two handles would unmake the same device twice. */
    DeviceHandle(const DeviceHandle &) = delete;

    /*! Non-assignable, for the same reason. */
    DeviceHandle &operator=(const DeviceHandle &) = delete;

    /*! Member access, so the handle can be used like the pointer it wraps. */
    SoapySDR::Device *operator->() const { return _dev; }

    /*! The raw pointer, for the calls that take a Device* argument. */
    SoapySDR::Device *get() const { return _dev; }

private:
    SoapySDR::Device *_dev;
};

/*!
 * Same idea for a stream: deactivate and close even if the test body throws,
 * so one failing test cannot leave the hardware running for the next one.
 */
class StreamHandle
{
public:
    /*!
     * Set up a stream on an already open device.
     *
     * \param dev    device to stream on; must outlive this handle
     * \param dir    SOAPY_SDR_RX or SOAPY_SDR_TX
     * \param fmt    wire format, SOAPY_SDR_CF32 or SOAPY_SDR_CS16
     * \param chans  channel indices to include in the stream
     */
    StreamHandle(SoapySDR::Device *dev, int dir, const std::string &fmt,
                 const std::vector<size_t> &chans)
        : _dev(dev), _stream(dev->setupStream(dir, fmt, chans)) {}

    /*!
     * Deactivate if still running, then close.  Exceptions are swallowed
     * because this may run while another exception is unwinding, and throwing
     * from a destructor at that point would terminate the process.
     */
    ~StreamHandle()
    {
        if (_stream == nullptr) return;
        try
        {
            if (_active) _dev->deactivateStream(_stream);
            _dev->closeStream(_stream);
        }
        catch (...) { /* nothing useful to do while unwinding */ }
    }

    /*! Non-copyable: two handles would close the same stream twice. */
    StreamHandle(const StreamHandle &) = delete;

    /*! Non-assignable, for the same reason. */
    StreamHandle &operator=(const StreamHandle &) = delete;

    /*! Start the stream and remember that it needs deactivating. */
    void activate() { _dev->activateStream(_stream); _active = true; }

    /*! The underlying stream, for the read and write calls. */
    SoapySDR::Stream *get() const { return _stream; }

private:
    SoapySDR::Device *_dev;
    SoapySDR::Stream *_stream;
    bool _active = false;
};

/***********************************************************************
 * Streaming statistics
 **********************************************************************/

/*!
 * Running totals for one capture.  Accumulated by accumulate(), printed by
 * reportRx(), and judged by rateIsHealthy().
 */
struct Stats
{
    size_t samples = 0;
    size_t reads = 0;
    size_t timeouts = 0;
    size_t discontinuities = 0;
    double sumPower = 0.0;
    long long firstTime = 0;
    long long lastTime = 0;
    bool haveTime = false;
    double wallSeconds = 0.0;

    /*!
     * Mean sample power over the whole capture.
     *
     * \return power in dBFS; the small epsilon keeps a silent capture from
     *         producing negative infinity
     */
    double meanPowerDb() const
    {
        return 10.0 * std::log10((samples ? sumPower / double(samples) : 0.0) + 1e-30);
    }

    /*!
     * Sample rate actually achieved, as opposed to the one requested.
     *
     * \return samples per second measured against the monotonic clock
     */
    double effectiveRate() const
    {
        return wallSeconds > 0.0 ? double(samples) / wallSeconds : 0.0;
    }
};

/*!
 * Fold one received block into a Stats total.
 *
 * \param st      totals to update
 * \param buf     the received samples
 * \param n       how many samples \p buf holds
 * \param flags   flags returned by readStream; SOAPY_SDR_HAS_TIME makes the
 *                timestamp meaningful and SOAPY_SDR_END_ABRUPT marks a gap
 * \param timeNs  timestamp of the first sample in the block
 */
void accumulate(Stats &st, const std::complex<float> *buf, size_t n,
                int flags, long long timeNs)
{
    for (size_t i = 0; i < n; i++)
        st.sumPower += double(buf[i].real()) * buf[i].real() +
                       double(buf[i].imag()) * buf[i].imag();
    st.samples += n;
    st.reads++;
    if ((flags & SOAPY_SDR_HAS_TIME) != 0)
    {
        if (!st.haveTime) { st.firstTime = timeNs; st.haveTime = true; }
        st.lastTime = timeNs;
    }
    if ((flags & SOAPY_SDR_END_ABRUPT) != 0) st.discontinuities++;
}

/*!
 * Receive for a fixed duration and collect statistics.
 *
 * Buffers are allocated per channel at twice the stream MTU as a safety
 * margin.  A CS16 stream is converted to complex float before accumulation so
 * the statistics are comparable across formats.
 *
 * \param dev      open device
 * \param o        options, for the sample rate used to size the capture
 * \param chans    channels to receive on
 * \param format   SOAPY_SDR_CF32 or SOAPY_SDR_CS16
 * \param seconds  how long to capture for
 * \param st       totals, filled in by this call
 * \return false on a hard stream error or a run of timeouts, true otherwise
 */
bool captureRx(SoapySDR::Device *dev, const Options &o,
               const std::vector<size_t> &chans, const std::string &format,
               double seconds, Stats &st)
{
    StreamHandle stream(dev, SOAPY_SDR_RX, format, chans);
    const size_t mtu = dev->getStreamMTU(stream.get());
    stream.activate();

    const size_t nch = chans.size();
    const bool cs16 = (format == SOAPY_SDR_CS16);

    // One buffer per channel.  Sized at 2x MTU purely as a safety margin.
    std::vector<std::vector<std::complex<float>>> bufs(
        nch, std::vector<std::complex<float>>(mtu * 2));
    std::vector<std::vector<int16_t>> bufs16(
        nch, std::vector<int16_t>(cs16 ? mtu * 4 : 0));

    std::vector<void *> buffs(nch);
    for (size_t k = 0; k < nch; k++)
        buffs[k] = cs16 ? static_cast<void *>(bufs16[k].data())
                        : static_cast<void *>(bufs[k].data());

    const size_t target = size_t(o.rate * seconds);
    const auto t0 = std::chrono::steady_clock::now();

    while (st.samples < target)
    {
        int flags = 0;
        long long timeNs = 0;
        const int ret = dev->readStream(stream.get(), buffs.data(), mtu,
                                        flags, timeNs, 1000000);
        if (ret == SOAPY_SDR_TIMEOUT)
        {
            if (++st.timeouts > 50)
            {
                std::cout << "   too many timeouts\n";
                return false;
            }
            continue;
        }
        if (ret < 0)
        {
            std::cout << "   readStream error " << ret
                      << " (" << SoapySDR::errToStr(ret) << ")\n";
            return false;
        }

        if (cs16)
            for (size_t k = 0; k < nch; k++)
                for (int i = 0; i < ret; i++)
                    bufs[k][i] = std::complex<float>(bufs16[k][2 * i] / 32768.0f,
                                                     bufs16[k][2 * i + 1] / 32768.0f);

        accumulate(st, bufs[0].data(), size_t(ret), flags, timeNs);
    }

    st.wallSeconds =
        std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
    return true;
}

/*!
 * Print a capture summary.  The timestamp span is worth comparing against the
 * sample count: if they disagree, samples went missing without the stream
 * reporting it.
 *
 * \param st              totals to print
 * \param configuredRate  rate that was asked for, for comparison
 */
void reportRx(const Stats &st, double configuredRate)
{
    std::cout << "   samples read    : " << st.samples << " in " << st.reads << " reads\n";
    std::cout << "   effective rate  : " << st.effectiveRate() / 1e6
              << " Msps (configured " << configuredRate / 1e6 << ")\n";
    std::cout << "   mean power      : " << st.meanPowerDb() << " dBFS\n";
    std::cout << "   timeouts        : " << st.timeouts << "\n";
    std::cout << "   discontinuities : " << st.discontinuities << "\n";
    if (st.haveTime)
    {
        const double span = double(st.lastTime - st.firstTime) / 1e9;
        std::cout << "   timestamp span  : " << span << " s ("
                  << (span > 0 ? double(st.samples) / span / 1e6 : 0.0)
                  << " Msps implied)\n";
    }
}

/*!
 * Decide whether a capture is healthy.
 *
 * Sample count alone is not enough: a stream that dropped data and then caught
 * up would still reach the target count.  A healthy capture therefore has to
 * hit the configured rate within five percent *and* report no discontinuities.
 *
 * \param st              totals from the capture
 * \param configuredRate  rate that was asked for
 * \return true when the capture ran at rate with no data lost
 */
bool rateIsHealthy(const Stats &st, double configuredRate)
{
    if (st.samples == 0) return false;
    if (st.discontinuities != 0) return false;
    const double ratio = st.effectiveRate() / configuredRate;
    return ratio > 0.95 && ratio < 1.05;
}

/*!
 * Fill a buffer with a complex tone, used by both transmit tests.
 *
 * \param buf        destination, filled completely
 * \param toneHz     tone offset from the carrier
 * \param rate       sample rate, so the phase step can be derived
 * \param phase      running phase, carried across calls so successive buffers
 *                   join without a discontinuity
 * \param amplitude  peak amplitude, well below full scale to leave headroom
 */
void makeTone(std::vector<std::complex<float>> &buf, double toneHz, double rate,
              double &phase, float amplitude)
{
    const double step = 2.0 * M_PI * toneHz / rate;
    for (auto &s : buf)
    {
        s = std::complex<float>(amplitude * float(std::cos(phase)),
                                amplitude * float(std::sin(phase)));
        phase += step;
        if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI;
    }
}

/***********************************************************************
 * Tests
 **********************************************************************/

/*!
 * Test 1: is the support module loaded and its factory registered?
 *
 * Runs before anything else, because every later test depends on it.
 *
 * \return true when a module with "amarisoft" in its path is loaded and the
 *         factory of that name is registered
 */
bool testModuleVisible()
{
    // Modules are discovered on disk but only loaded on demand, and the
    // registry is populated by that load.  Querying listFindFunctions()
    // without this would report no factories even for a perfectly good
    // installation.
    SoapySDR::loadModules();

    std::cout << "   root path     : " << SoapySDR::getRootPath() << "\n";
    for (const auto &p : SoapySDR::listSearchPaths())
        std::cout << "   search path   : " << p << "\n";

    bool found = false;
    for (const auto &m : SoapySDR::listModules())
    {
        std::cout << "   module        : " << m << "\n";
        if (m.find("amarisoft") != std::string::npos) found = true;
    }

    bool factory = false;
    for (const auto &kv : SoapySDR::Registry::listFindFunctions())
    {
        std::cout << "   factory       : " << kv.first << "\n";
        if (kv.first == "amarisoft") factory = true;
    }

    if (!found)
        std::cout << "   no module with 'amarisoft' in its name was loaded\n";
    if (!factory)
        std::cout << "   the 'amarisoft' factory is not registered - is the "
                     "module installed to a SoapySDR search path?\n";

    return found && factory;
}

/*!
 * Test 2: does the factory enumerate devices?
 *
 * Also reports which boards another process already holds open, since that is
 * the usual explanation for a later open failure.
 *
 * \param o  options; if a device was named, it must appear in the listing
 * \return true when at least one device is reported
 */
bool testEnumerate(const Options &o)
{
    const auto nodes = enumerateDevices();
    const auto busy = busyNodes();

    for (const auto &n : nodes)
        std::cout << "   " << n << (busy.count(n) ? "   (in use by another process)" : "")
                  << "\n";

    if (nodes.empty())
    {
        std::cout << "   no devices found - is the Amarisoft 'sdr' kernel "
                     "module loaded?\n";
        return false;
    }
    if (!o.device.empty() &&
        std::find(nodes.begin(), nodes.end(), o.device) == nodes.end())
    {
        std::cout << "   requested device " << o.device << " was not enumerated\n";
        return false;
    }
    return true;
}

/*!
 * Test 3: open the device and print what it says about itself.
 *
 * Exercises identification, channel counts, gain and frequency ranges, the
 * stream formats and one sensor read.
 *
 * \param o  options selecting the device
 * \return true when the driver identifies as "amarisoft" with at least one
 *         receive channel
 */
bool testInfo(const Options &o)
{
    DeviceHandle dev(o);

    std::cout << "   driver        : " << dev->getDriverKey() << "\n";
    std::cout << "   hardware      : " << dev->getHardwareKey() << "\n";
    for (const auto &kv : dev->getHardwareInfo())
        std::cout << "   " << std::setw(14) << std::left << kv.first << ": " << kv.second << "\n";

    const size_t nrx = dev->getNumChannels(SOAPY_SDR_RX);
    const size_t ntx = dev->getNumChannels(SOAPY_SDR_TX);
    std::cout << "   channels      : " << nrx << " RX, " << ntx << " TX\n";

    const auto gr = dev->getGainRange(SOAPY_SDR_RX, 0);
    std::cout << "   RX gain range : " << gr.minimum() << " .. " << gr.maximum() << " dB\n";

    const auto fr = dev->getFrequencyRange(SOAPY_SDR_RX, 0);
    if (!fr.empty())
        std::cout << "   RX freq range : " << fr.front().minimum() / 1e6 << " .. "
                  << fr.back().maximum() / 1e6 << " MHz\n";

    std::cout << "   formats       : ";
    for (const auto &f : dev->getStreamFormats(SOAPY_SDR_RX, 0)) std::cout << f << " ";
    std::cout << "\n";

    std::cout << "   fpga temp     : " << dev->readSensor("fpga_temp") << " C\n";

    return dev->getDriverKey() == "amarisoft" && nrx > 0;
}

/*!
 * Tests 4 to 7: configure the receiver and stream from it.
 *
 * Used for every receive variant: the two sample rates, the CS16 format and
 * the two channel case.
 *
 * \param o       options for frequency, gain, antenna and duration
 * \param format  SOAPY_SDR_CF32 or SOAPY_SDR_CS16
 * \param rate    sample rate for this run, which may differ from o.rate
 * \param chans   channels to stream
 * \return true when the capture was healthy, per rateIsHealthy()
 */
bool testRx(const Options &o, const std::string &format,
            double rate, const std::vector<size_t> &chans)
{
    DeviceHandle dev(o);

    dev->setSampleRate(SOAPY_SDR_RX, chans.front(), rate);
    for (const auto ch : chans)
    {
        dev->setFrequency(SOAPY_SDR_RX, ch, o.freq);
        dev->setGain(SOAPY_SDR_RX, ch, o.rxGain);
        if (!o.antenna.empty()) dev->setAntenna(SOAPY_SDR_RX, ch, o.antenna);
    }

    std::cout << "   " << dev->getSampleRate(SOAPY_SDR_RX, chans.front()) / 1e6
              << " Msps, " << dev->getFrequency(SOAPY_SDR_RX, chans.front()) / 1e6
              << " MHz, " << format << ", antenna "
              << dev->getAntenna(SOAPY_SDR_RX, chans.front()) << ", channels";
    for (const auto ch : chans) std::cout << " " << ch;
    std::cout << "\n";

    Options local = o;
    local.rate = rate;

    Stats st;
    if (!captureRx(dev.get(), local, chans, format, o.seconds, st)) return false;

    reportRx(st, rate);
    std::cout << "   rx overflows    : " << dev->readSensor("rx_overflow_count") << "\n";

    return rateIsHealthy(st, rate);
}

/*!
 * Test 8: retune while a stream is live.
 *
 * The AD9361 front end has no working live retune, so the module applies a
 * frequency change with a full stop, reconfigure and restart cycle.  What is
 * really under test is that streaming resumes cleanly afterwards, at each of
 * five frequencies including one far outside the original band.
 *
 * \param o  options for the starting frequency, rate and gain
 * \return true when every retune was reported back correctly and samples
 *         continued to flow after each one
 */
bool testRetune(const Options &o)
{
    DeviceHandle dev(o);

    const std::vector<size_t> chans = {0};
    dev->setSampleRate(SOAPY_SDR_RX, 0, o.rate);
    dev->setFrequency(SOAPY_SDR_RX, 0, o.freq);
    dev->setGain(SOAPY_SDR_RX, 0, o.rxGain);

    StreamHandle stream(dev.get(), SOAPY_SDR_RX, SOAPY_SDR_CF32, chans);
    const size_t mtu = dev->getStreamMTU(stream.get());
    stream.activate();

    std::vector<std::complex<float>> buf(mtu * 2);
    void *buffs[1] = {buf.data()};

    const double freqs[] = {o.freq, o.freq + 20e6, o.freq - 20e6, 751e6, o.freq};
    bool ok = true;

    for (const double f : freqs)
    {
        const auto t0 = std::chrono::steady_clock::now();
        dev->setFrequency(SOAPY_SDR_RX, 0, f);
        const double tuneMs = std::chrono::duration<double, std::milli>(
            std::chrono::steady_clock::now() - t0).count();

        Stats st;
        const auto t1 = std::chrono::steady_clock::now();
        const size_t want = size_t(o.rate * 0.2);
        while (st.samples < want)
        {
            int flags = 0;
            long long timeNs = 0;
            const int ret = dev->readStream(stream.get(), buffs, mtu, flags, timeNs, 1000000);
            if (ret == SOAPY_SDR_TIMEOUT) { if (++st.timeouts > 20) break; continue; }
            if (ret < 0) { std::cout << "   readStream error " << ret << "\n"; break; }
            accumulate(st, buf.data(), size_t(ret), flags, timeNs);
        }
        st.wallSeconds =
            std::chrono::duration<double>(std::chrono::steady_clock::now() - t1).count();

        std::cout << "   tuned to " << std::setw(8) << f / 1e6 << " MHz in "
                  << std::setw(8) << tuneMs << " ms | " << std::setw(9) << st.samples
                  << " samples | mean " << st.meanPowerDb() << " dBFS\n";

        if (st.samples == 0)
        {
            std::cout << "     no samples after retune\n";
            ok = false;
        }
        if (dev->getFrequency(SOAPY_SDR_RX, 0) != f)
        {
            std::cout << "     getFrequency reports "
                      << dev->getFrequency(SOAPY_SDR_RX, 0) << "\n";
            ok = false;
        }
    }
    return ok;
}

/*!
 * Test 9: transmit a tone and check that it is paced.
 *
 * Gain is forced to 0 dB so the data path is exercised without meaningful
 * emission.  Success is not merely that writeStream returned: msdr_write does
 * not block, so an unthrottled writer would race ahead of the hardware clock.
 * The achieved rate therefore has to land close to the configured rate.
 *
 * \param o  options for frequency, rate and duration
 * \return true when the full burst was written, paced to rate, with no errors
 */
bool testTx(const Options &o)
{
    DeviceHandle dev(o);

    dev->setSampleRate(SOAPY_SDR_TX, 0, o.rate);
    dev->setFrequency(SOAPY_SDR_TX, 0, o.freq);
    dev->setGain(SOAPY_SDR_TX, 0, 0.0);   // minimum gain: exercise the path, not the PA

    std::cout << "   " << dev->getSampleRate(SOAPY_SDR_TX, 0) / 1e6 << " Msps, "
              << dev->getFrequency(SOAPY_SDR_TX, 0) / 1e6 << " MHz, "
              << dev->getGain(SOAPY_SDR_TX, 0) << " dB\n";

    StreamHandle stream(dev.get(), SOAPY_SDR_TX, SOAPY_SDR_CF32, {0});
    const size_t mtu = dev->getStreamMTU(stream.get());
    stream.activate();

    std::vector<std::complex<float>> buf(mtu);
    double phase = 0.0;
    size_t written = 0;
    size_t errors = 0;

    const size_t target = size_t(o.rate * o.seconds);
    const auto t0 = std::chrono::steady_clock::now();

    while (written < target)
    {
        makeTone(buf, o.toneHz, o.rate, phase, 0.3f);
        const void *buffs[1] = {buf.data()};
        int flags = 0;
        const int ret = dev->writeStream(stream.get(), buffs, buf.size(), flags, 0, 1000000);
        if (ret < 0)
        {
            if (++errors > 10)
            {
                std::cout << "   writeStream error " << ret
                          << " (" << SoapySDR::errToStr(ret) << ")\n";
                break;
            }
            continue;
        }
        written += size_t(ret);
    }
    const double wall =
        std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();

    const double rate = wall > 0.0 ? double(written) / wall : 0.0;
    std::cout << "   samples written : " << written << " in " << wall << " s ("
              << rate / 1e6 << " Msps)\n";
    std::cout << "   write errors    : " << errors << "\n";
    std::cout << "   tx underflows   : " << dev->readSensor("tx_underflow_count") << "\n";

    // The module throttles transmit to real time, so the achieved rate should
    // land close to the configured rate rather than racing ahead of it.
    const double ratio = rate / o.rate;
    if (ratio < 0.95 || ratio > 1.15)
    {
        std::cout << "   transmit rate is not paced to the configured rate\n";
        return false;
    }
    return written >= target && errors == 0;
}

/*!
 * Test 10: transmit and receive at the same time.
 *
 * Each received block is mixed down by the transmitted tone offset and
 * integrated.  A tone that really arrives survives the integration whatever
 * its arrival phase, while uncorrelated noise averages away; the result is
 * compared against the 1/N floor that noise alone would produce for an
 * N sample block.
 *
 * Whether the tone couples back depends on the RF setup, so a missing tone is
 * reported but not treated as a failure.  Simultaneous full duplex streaming
 * is the thing being tested.
 *
 * \param o  options for frequency, gain, tone offset and duration
 * \return true when both directions streamed without discontinuities
 */
bool testLoopback(const Options &o)
{
    DeviceHandle dev(o);

    dev->setSampleRate(SOAPY_SDR_RX, 0, o.rate);
    dev->setFrequency(SOAPY_SDR_RX, 0, o.freq);
    dev->setGain(SOAPY_SDR_RX, 0, o.rxGain);
    dev->setFrequency(SOAPY_SDR_TX, 0, o.freq);
    dev->setGain(SOAPY_SDR_TX, 0, o.txGain);

    // Activate TX first: bringing up a direction the hardware was not started
    // with forces a restart, and doing that after RX is live would interrupt it.
    StreamHandle tx(dev.get(), SOAPY_SDR_TX, SOAPY_SDR_CF32, {0});
    StreamHandle rx(dev.get(), SOAPY_SDR_RX, SOAPY_SDR_CF32, {0});
    tx.activate();
    rx.activate();

    const size_t mtu = dev->getStreamMTU(rx.get());
    std::cout << "   RX " << dev->getFrequency(SOAPY_SDR_RX, 0) / 1e6 << " MHz, TX "
              << dev->getFrequency(SOAPY_SDR_TX, 0) / 1e6 << " MHz, tone +"
              << o.toneHz / 1e6 << " MHz, TX gain " << o.txGain << " dB\n";

    std::vector<std::complex<float>> txBuf(mtu);
    std::vector<std::complex<float>> rxBuf(mtu * 2);
    double phase = 0.0;
    Stats st;
    size_t sent = 0;
    double sumRatio = 0.0;
    size_t blocks = 0;

    const double step = 2.0 * M_PI * o.toneHz / o.rate;
    const size_t target = size_t(o.rate * o.seconds);
    const auto t0 = std::chrono::steady_clock::now();

    while (st.samples < target)
    {
        makeTone(txBuf, o.toneHz, o.rate, phase, 0.3f);
        const void *txBuffs[1] = {txBuf.data()};
        int txFlags = 0;
        const int wr = dev->writeStream(tx.get(), txBuffs, txBuf.size(), txFlags, 0, 100000);
        if (wr > 0) sent += size_t(wr);

        void *rxBuffs[1] = {rxBuf.data()};
        int flags = 0;
        long long timeNs = 0;
        const int ret = dev->readStream(rx.get(), rxBuffs, mtu, flags, timeNs, 1000000);
        if (ret == SOAPY_SDR_TIMEOUT) { if (++st.timeouts > 50) break; continue; }
        if (ret < 0) { std::cout << "   readStream error " << ret << "\n"; return false; }
        accumulate(st, rxBuf.data(), size_t(ret), flags, timeNs);

        std::complex<double> acc(0.0, 0.0);
        double blockPower = 0.0;
        for (int i = 0; i < ret; i++)
        {
            const std::complex<double> s(rxBuf[i].real(), rxBuf[i].imag());
            acc += s * std::complex<double>(std::cos(-step * i), std::sin(-step * i));
            blockPower += std::norm(s);
        }
        if (blockPower > 0.0 && ret > 0)
        {
            sumRatio += (std::norm(acc) / double(ret)) / blockPower;
            blocks++;
        }
    }
    st.wallSeconds =
        std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();

    reportRx(st, o.rate);
    std::cout << "   samples sent    : " << sent << "\n";
    std::cout << "   tx underflows   : " << dev->readSensor("tx_underflow_count") << "\n";

    if (blocks == 0) return false;

    // For an N-sample block, uncorrelated noise integrates down to 1/N of the
    // total power, so that is the floor this has to beat.
    const double meanDb = 10.0 * std::log10(sumRatio / double(blocks) + 1e-30);
    const double floorDb = 10.0 * std::log10(1.0 / double(mtu));
    std::cout << "   tone at +" << o.toneHz / 1e6 << " MHz: " << meanDb
              << " dB vs " << floorDb << " dB noise floor\n";

    if (meanDb < floorDb + 6.0)
    {
        std::cout << "   tone not detected - this is expected if the TX and RX\n"
                     "   ports are isolated; streaming itself still worked\n";
        // Full duplex streaming is the thing under test here.  Whether the
        // tone couples back depends on the RF setup, so do not fail on it.
    }

    return st.samples > 0 && sent > 0 && st.discontinuities == 0;
}

/*!
 * Test 11: check that an impossible frequency is refused safely.
 *
 * A frequency the AD9361 cannot produce is a fatal error inside libsdr: it
 * prints a message and calls exit(), taking the caller with it.  The module
 * range checks before the request reaches libsdr, so the correct outcome is
 * an exception followed by a device that still works.
 *
 * If this test never prints its result, the guard has been bypassed and the
 * process was killed.
 *
 * \param o  options selecting the device and the frequency to return to
 * \return true when both out of range requests threw and the device survived
 */
bool testFrequencyGuard(const Options &o)
{
    DeviceHandle dev(o);

    const double bad[] = {100e6, 6.5e9};
    bool ok = true;

    for (const double f : bad)
    {
        bool threw = false;
        try
        {
            dev->setFrequency(SOAPY_SDR_RX, 0, f);
        }
        catch (const std::exception &ex)
        {
            threw = true;
            std::cout << "   " << f / 1e6 << " MHz rejected: " << ex.what() << "\n";
        }
        if (!threw)
        {
            std::cout << "   " << f / 1e6 << " MHz was accepted - expected a rejection\n";
            ok = false;
        }
    }

    // Still usable afterwards?  A guard that leaves the device broken is not
    // much better than a crash.
    dev->setFrequency(SOAPY_SDR_RX, 0, o.freq);
    std::cout << "   device still usable, retuned to "
              << dev->getFrequency(SOAPY_SDR_RX, 0) / 1e6 << " MHz\n";

    return ok;
}

/***********************************************************************
 * main
 **********************************************************************/

/*!
 * Print the command line reference.
 *
 * \param argv0  program name as invoked, so the example line matches
 */
void usage(const char *argv0)
{
    std::cout <<
        "usage: " << argv0 << " [/dev/sdrN] [options]\n"
        "\n"
        "  Runs the Amarisoft SoapySDR module through its paces using only\n"
        "  the public SoapySDR API. With no device given, a board that no\n"
        "  other process is holding open is selected automatically.\n"
        "\n"
        "options:\n"
        "  --tx           also run the transmit tests (these key the TX port)\n"
        "  --seconds S    capture duration per streaming test (default 2)\n"
        "  --freq HZ      centre frequency (default 2140e6)\n"
        "  --rate HZ      sample rate (default 23.04e6)\n"
        "  --gain DB      RX gain (default 40)\n"
        "  --tx-gain DB   TX gain for the loopback test (default 20)\n"
        "  --tone HZ      loopback tone offset (default 2e6)\n"
        "  --antenna NAME RX antenna: RX (separate connector) or TX_RX (shared,\n"
        "                 the usual choice for a TDD band such as n78)\n"
        "  --verbose      turn on SoapySDR debug logging\n"
        "  --help         this message\n";
}

} // anonymous namespace

/*!
 * Parse the command line, choose a device and run the tests in order.
 *
 * The module and enumeration tests run first and abort the run if either
 * fails, since nothing later can succeed without them.  If no device was
 * named, one that no other process holds open is selected.  The transmit
 * tests run only when --tx was given.
 *
 * \return 0 when every test that ran passed, 1 otherwise
 */
int main(int argc, char **argv)
{
    Options o;

    for (int i = 1; i < argc; i++)
    {
        const std::string a = argv[i];
        auto next = [&]() -> std::string {
            if (i + 1 >= argc) throw std::runtime_error("missing value for " + a);
            return argv[++i];
        };

        try
        {
            if (a == "--help" || a == "-h") { usage(argv[0]); return 0; }
            else if (a == "--tx") o.runTx = true;
            else if (a == "--seconds") o.seconds = std::stod(next());
            else if (a == "--freq") o.freq = std::stod(next());
            else if (a == "--rate") o.rate = std::stod(next());
            else if (a == "--gain") o.rxGain = std::stod(next());
            else if (a == "--tx-gain") o.txGain = std::stod(next());
            else if (a == "--tone") o.toneHz = std::stod(next());
            else if (a == "--antenna") o.antenna = next();
            else if (a == "--verbose") { o.verbose = true; SoapySDR::setLogLevel(SOAPY_SDR_DEBUG); }
            else if (!a.empty() && a[0] != '-') o.device = a;
            else { std::cerr << "unknown option " << a << "\n\n"; usage(argv[0]); return 2; }
        }
        catch (const std::exception &ex)
        {
            std::cerr << "ERROR: " << ex.what() << "\n";
            return 2;
        }
    }

    if (!o.verbose) SoapySDR::setLogLevel(SOAPY_SDR_WARNING);

    std::cout << "hello soapy - Amarisoft SoapySDR module check\n";

    Harness h;

    // These two run before any device is opened, because if the module is not
    // loaded there is no point attempting anything else.
    h.run("1. Module loaded and factory registered", [&] { return testModuleVisible(); });
    h.run("2. Device enumeration", [&] { return testEnumerate(o); });

    if (h.failed() > 0)
    {
        std::cout << "\nModule or enumeration check failed; skipping the rest.\n";
        std::cout << "  passed: " << h.passed() << "   failed: " << h.failed() << "\n";
        return 1;
    }

    // Choose a board that nobody else is using, unless one was named.
    if (o.device.empty())
    {
        const auto nodes = enumerateDevices();
        const auto busy = busyNodes();
        for (const auto &n : nodes)
            if (busy.count(n) == 0) { o.device = n; break; }

        if (o.device.empty())
        {
            std::cout << "\nEvery SDR device is currently in use by another "
                         "process. Free one, or name a device explicitly.\n";
            return 1;
        }
        std::cout << "\nAuto-selected free device: " << o.device << "\n";
    }
    else
    {
        if (busyNodes().count(o.device))
            std::cout << "\nWARNING: " << o.device << " looks like it is already open "
                         "by another process; msdr_open will most likely fail.\n";
        std::cout << "\nUsing device: " << o.device << "\n";
    }

    h.run("3. Device info",              [&] { return testInfo(o); });
    // Test 4 runs at whatever --rate asked for, so pointing the tool at a real
    // channel actually exercises that channel.  Test 6 then runs at a second,
    // different rate purely to prove the rate-change path works; if --rate is
    // already 30.72 Msps it falls back to 23.04 so the two never coincide.
    const double altRate = (std::fabs(o.rate - 30.72e6) < 1e3) ? 23.04e6 : 30.72e6;

    auto rateLabel = [](const char *prefix, double r) {
        std::ostringstream oss;
        oss << prefix << " @ " << r / 1e6 << " Msps";
        return oss.str();
    };

    h.run(rateLabel("4. RX CF32", o.rate),  [&] { return testRx(o, SOAPY_SDR_CF32, o.rate, {0}); });
    h.run("5. RX CS16",                     [&] { return testRx(o, SOAPY_SDR_CS16, o.rate, {0}); });
    h.run(rateLabel("6. RX CF32", altRate), [&] { return testRx(o, SOAPY_SDR_CF32, altRate, {0}); });
    h.run("7. RX 2-channel MIMO",           [&] { return testRx(o, SOAPY_SDR_CF32, o.rate, {0, 1}); });
    h.run("8. Retune while streaming",   [&] { return testRetune(o); });

    if (o.runTx)
    {
        h.run("9. TX (gain 0)",          [&] { return testTx(o); });
        h.run("10. Full-duplex loopback",[&] { return testLoopback(o); });
    }
    else
    {
        h.skip("TX and loopback", "pass --tx to include them; they key the TX port");
    }

    h.run("11. Out-of-range frequency guard", [&] { return testFrequencyGuard(o); });

    std::cout << "\n============================================================\n";
    std::cout << "  passed: " << h.passed()
              << "   failed: " << h.failed()
              << "   skipped: " << h.skipped()
              << "   device: " << o.device << "\n";
    std::cout << "============================================================\n";

    return h.failed() == 0 ? 0 : 1;
}

detectssb - Demonstrating Real Time Capability

Everything up to this point establishes that the support module is correct: it enumerates, it configures, it streams the right samples and it releases the hardware cleanly. That leaves one question a driver author still has to answer, and it is the one that decides whether the module is usable for real work. Is the abstraction fast enough? A layer that copies samples correctly but cannot sustain the radio's own sample rate is of little use, because the hardware will simply overflow and discard whatever the application failed to collect in time.

The purpose of 'detectssb' is to answer that question with a task that cannot be faked. It is a small 5G NR receiver: it tunes to an SSB, correlates the incoming samples against the three PSS sequences to recover N_ID_2 and the symbol timing, demodulates the SSS two symbols later to recover N_ID_1, and reports the physical cell identity together with the hardware timestamp of the SSB. All of that runs continuously on the live sample stream, with no recording and no offline pass.

This makes a good demonstration for three reasons. The work is genuinely heavy, since the correlator processes every sample the radio produces rather than a decimated or duty-cycled subset. The answer is verifiable, because the cell identity it prints either matches the cell that is transmitting or it does not. And the timing is self-checking: an NR cell repeats its SSB on a fixed period, so the interval between reported timestamps proves directly whether any were missed.

The program reaches the hardware only through the SoapySDR interface, exactly as any third party application would. It contains no Amarisoft header and no libsdr symbol, so whatever performance it achieves is the performance the module delivers to ordinary SoapySDR clients.

Running it

The cell is named the way 3GPP names it: the SSB centre frequency as an NR-ARFCN, plus the subcarrier spacing. From those two numbers the program derives the tuning frequency and picks a sample rate of 256 times the subcarrier spacing, which makes one OFDM symbol exactly a 256 point FFT and happens to land on the sample rate ladder the Amarisoft hardware expects.

cd /root/soapy_project/detectssb

./build.sh

./build/detectssb --arfcn 631968 --scs 30 --device /dev/sdr0 --gain 6

NOTE : Give it the SSB ARFCN, not the cell centre. In an Amarisoft configuration these are separate fields: 'ssb_nr_arfcn' is the one to use, 'dl_nr_arfcn' points at the middle of the carrier and will not work.

Example run

The following is a half second capture against a live band n78 cell. The '--seconds 0.5' argument simply bounds the run for the sake of the example; left alone the program runs until interrupted.

[root@UESB-2021102500 build]# ./detectssb --arfcn 631968 --scs 30 --device /dev/sdr0 --gain 6 --seconds 0.5
detectssb - 5G NR SSB detection
  ARFCN         : 631968
  SSB centre    : 3479.520 MHz
  SCS           : 30.000 kHz
  sample rate   : 7.680 Msps  (256 point FFT per symbol)
  PSS/SSS span  : 3.810 MHz on bins -64 .. 62
  symbol/CP     : 256 + 18 = 274 samples, SSS at +548
  threshold     : 0.080
  device        : /dev/sdr0 (AD9361)
  tuned         : 3479.520 MHz at 7.680 Msps, gain 6.000 dB

listening (Ctrl-C to stop)

        time      timestamp(ns)   NID_2  NID_1   PCI   PSS    SSS
  ------------  ---------------   -----  -----  ----  -----  ------
      0.0030         12589974       2    166   500  0.991   17.1 dB
      0.0228         32589974       2    166   500  0.989   17.2 dB
      0.0429         52589974       2    166   500  0.987   17.0 dB
      0.0630         72589974       2    166   500  0.984   17.1 dB
      0.0828         92589974       2    166   500  0.981   17.1 dB
      0.1029        112589974       2    166   500  0.978   17.1 dB
      0.1230        132589974       2    166   500  0.975   17.1 dB
      0.1428        152589974       2    166   500  0.973   17.1 dB
      0.1629        172589974       2    166   500  0.969   17.0 dB
      0.1830        192589974       2    166   500  0.966   17.1 dB
      0.2028        212589974       2    166   500  0.962   17.1 dB
      0.2229        232589974       2    166   500  0.959   17.1 dB
      0.2430        252589974       2    166   500  0.954   17.0 dB
      0.2628        272589974       2    166   500  0.949   17.1 dB
      0.2829        292589974       2    166   500  0.943   17.0 dB
      0.3030        312589974       2    166   500  0.938   17.1 dB
      0.3228        332589974       2    166   500  0.933   17.0 dB
      0.3429        352589974       2    166   500  0.927   17.0 dB
      0.3630        372589974       2    166   500  0.920   17.0 dB
      0.3828        392589974       2    166   500  0.916   17.0 dB
      0.4029        412589974       2    166   500  0.907   17.1 dB
      0.4230        432589974       2    166   500  0.900   17.1 dB
      0.4428        452589974       2    166   500  0.893   17.0 dB
      0.4629        472589974       2    166   500  0.885   17.1 dB
      0.4830        492589974       2    166   500  0.879   16.9 dB

--- summary ---
  detections    : 25
  samples       : 3842048 in 0.5 s
  throughput    : 8 Msps (real time needs 8)
  rx overflows  : 0

What the output shows

The cell is identified as N_ID_2 = 2 from the PSS and N_ID_1 = 166 from the SSS, giving PCI = 3 x 166 + 2 = 500. The same identity is reported on every one of the twenty five detections, and the two quality figures back it up. A PSS correlation approaching 1.0 is very nearly a perfect match against the reference waveform, and a 17 dB SSS margin means the winning N_ID_1 stands well clear of the other three hundred and thirty five candidates. For contrast, when nothing is transmitting on the tuned frequency the correlation sits at about 0.08, which is simply the largest value one expects from noise alone.

The real time claim is settled by three independent things in that output, and all three have to agree.

A failure of real time processing shows up as at least one of those three going wrong, usually all of them together. The program also prints an explicit warning if throughput falls below ninety five percent of the configured rate, so a marginal machine reports itself rather than quietly missing cells.

One detail in the listing is worth explaining because it looks like a lag and is not. The 'time' column counts from the moment the program entered its streaming loop, while 'timestamp' is the radio's own sample counter, which started earlier when the stream was activated. The roughly ten millisecond difference between them is that difference in origin. What matters is that the gap stays constant: were the program falling behind the radio, it would grow without bound.

Source code

The complete program follows. It is a single file of about a thousand lines with no dependency other than SoapySDR, and it divides into four parts: the 3GPP definitions, meaning the ARFCN conversion and the PSS and SSS sequence generators taken directly from TS 38.211; a small radix-2 FFT, included so the program stays self contained; the detector itself, which holds the correlator state and the decode; and the streaming loop in main.

As in the earlier listing, the calls that any SoapySDR application must make are highlighted. Comparing them with the same highlights in 'hellosoapy.cpp' is instructive: the sequence is identical, but the two programs manage it differently. There the calls are wrapped in RAII types so that the hardware is released even when a check throws; here they are written out in the body of main with the teardown after the catch block, which is the more conventional shape and is adequate because there is only one device and one stream to release.

NOTE : RAII stands for Resource Acquisition Is Initialisation, a C++ idiom in which a constructor acquires a resource and the matching destructor releases it. A destructor is guaranteed to run when its object leaves scope, including while an exception is unwinding, so the release cannot be skipped. That matters with this hardware because the SDR is opened exclusively: a program that throws past its cleanup leaves the board held for the life of the process, and every later attempt to open it fails. 'hellosoapy.cpp' uses the idiom because eleven checks share one device and any of them may throw; this program has a single exit path and does not need it. The equivalent in Python is a 'finally' block, which is what 'rx_minimal.py' uses.

Two parts of the file are there for testing rather than for detection, and can be ignored on a first reading. 'selfTest' runs a synthetic SSB through the detector with no radio involved, which is what separates a bug in this program from an RF problem; 'synthesiseSsb' builds that signal and is reused by the loopback mode to transmit a known cell identity while receiving.

The diagram below shows how the program hangs together, starting from 'main()'. The second row is the dispatch: '--help' and the ARFCN conversion are trivial, '--self-test' builds its own signal and never opens the radio, and the streaming loop is the normal path. Both the self test and the streaming loop feed the same 'Detector', which is what makes the self test meaningful - it exercises the identical correlator and decode, only with a synthetic input.

shared primitives - TS 38.211 sequences and the transform class Detector - correlator state and decode main() parse, dispatch, stream usage() --help arfcnToHz() ARFCN -> Hz selfTest() --self-test, no radio streaming loop the normal path buildSsbFrame() --loopback frame Device / Stream read, write synthesiseSsb() one SSB, PSS + SSS Detector() build references push() append takeBestSeen() diagnostic peak process() overlap-save correlateBlock() 3 x PSS decodeSss() 336 hypotheses fft() pssSequence() sssSequence()

Following is the entire source code. Try to get the overall structure of the code shown above and then look into the code to find the details. Or just copy the entire code into an AI agent (e.g, Claude Code , Codex etc) and ask the details

//
// detectssb - real time 5G NR SSB detection over SoapySDR
//
// Streams IQ from an Amarisoft SDR card through the SoapySDR abstraction and
// searches continuously for the Synchronisation Signal Block of an NR cell.
// For every SSB it finds it reports N_ID_2 (from the PSS), N_ID_1 (from the
// SSS), the resulting physical cell identity, and the hardware timestamp of
// the PSS symbol.
//
// The cell is named the way 3GPP names it: an SSB centre frequency given as an
// NR-ARFCN plus the subcarrier spacing.  SISO only - one receive channel.
//
//   PCI = 3 * N_ID_1 + N_ID_2      (TS 38.211 7.4.2.1)
//
// Build:  ./build.sh
// Run:    ./build/detectssb --arfcn 632628 --scs 30
//
// SPDX-License-Identifier: MIT
//

#include <SoapySDR/Device.hpp>
#include <SoapySDR/Errors.hpp>
#include <SoapySDR/Formats.hpp>
#include <SoapySDR/Logger.hpp>

#include <algorithm>
#include <chrono>
#include <cmath>
#include <complex>
#include <csignal>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <string>
#include <vector>

namespace {

/***********************************************************************
 * OFDM geometry
 *
 * The receiver samples at 256 x SCS, so one OFDM symbol is exactly a
 * 256 point FFT.  That rate is a multiple of 1.92 MHz for every SCS in
 * use, which is what the Amarisoft hardware wants, and it places the
 * 127 subcarrier PSS across the middle half of the band, comfortably
 * inside the analogue filter.
 *
 * PSS and SSS both occupy the centre 127 subcarriers of the SSB, which
 * land on FFT bins -64 .. +62.  The rest of the 240 subcarrier SSB
 * (PBCH) falls outside the captured band and is not needed here.
 **********************************************************************/

//! OFDM FFT size, and therefore samples per symbol body.
constexpr int N_FFT = 256;

//! Normal cyclic prefix, scaled from the 144 samples used at a 2048 point FFT.
constexpr int N_CP = 144 * N_FFT / 2048;          // 18

//! Symbol period including its cyclic prefix.
constexpr int SYM_STRIDE = N_FFT + N_CP;          // 274

//! SSS sits two symbols after PSS inside the SSB (PSS, PBCH, SSS, PBCH).
constexpr int SSS_OFFSET = 2 * SYM_STRIDE;        // 548

//! Length of the PSS and SSS sequences in subcarriers.
constexpr int N_SEQ = 127;

//! Lowest FFT bin occupied by PSS/SSS, relative to the SSB centre.
constexpr int SEQ_BIN0 = -64;

//! Samples that must already be buffered past a correlation peak before the
//! detection can be decoded: the PSS symbol plus the gap to the end of SSS.
constexpr int NEED_AHEAD = SSS_OFFSET + N_FFT;    // 804

//! Overlap-save block size and the resulting hop.
constexpr int CORR_M = 2048;
constexpr int CORR_HOP = CORR_M - N_FFT + 1;      // 1793

//! There are three PSS sequences, one per N_ID_2.
constexpr int N_NID2 = 3;

//! N_ID_1 ranges over 0..335, giving 1008 cell identities in total.
constexpr int N_NID1 = 336;

/***********************************************************************
 * Options
 **********************************************************************/

/*!
 * Everything the command line can influence.  The defaults describe no
 * particular cell, so --arfcn and --scs are effectively required.
 */
struct Options
{
    std::string device;            //!< empty => first free /dev/sdrN
    long long arfcn = -1;          //!< NR-ARFCN of the SSB centre
    double scsKhz = 30.0;          //!< subcarrier spacing in kHz
    double gain = 40.0;            //!< RX gain in dB
    double seconds = 0.0;          //!< 0 => run until interrupted
    double threshold = 0.08;       //!< normalised PSS correlation to accept
    double repeatSec = 0.0;        //!< suppress repeats of a PCI for this long
    bool listOnly = false;         //!< print the plan and exit without opening
    bool selfTest = false;         //!< run the synthetic check instead
    int txPci = -1;                //!< >=0 => transmit this PCI while receiving
    double txGain = 20.0;          //!< TX gain for the loopback mode
    double snrDb = 10.0;           //!< SNR used by the self test
    bool verbose = false;          //!< SoapySDR debug logging
};

/*! Set by the SIGINT handler so the streaming loop can exit cleanly. */
volatile std::sig_atomic_t g_stop = 0;

/*!
 * Ask the streaming loop to stop at the next block boundary.
 *
 * \param  unused signal number
 */
void onSignal(int) { g_stop = 1; }

/***********************************************************************
 * NR-ARFCN
 **********************************************************************/

/*!
 * Convert an NR-ARFCN to a frequency, per TS 38.104 table 5.4.2.1-1.
 *
 * Three ranges apply, each with its own global raster step and offset.
 *
 * \param arfcn  the channel number
 * \return the RF reference frequency in Hz, or 0 if the ARFCN is out of range
 */
double arfcnToHz(long long arfcn)
{
    if (arfcn < 0)            return 0.0;
    if (arfcn <= 599999)      return 5e3 * double(arfcn);
    if (arfcn <= 2016666)     return 3000e6 + 15e3 * double(arfcn - 600000);
    if (arfcn <= 3279165)     return 24250.08e6 + 60e3 * double(arfcn - 2016667);
    return 0.0;
}

/***********************************************************************
 * Minimal FFT
 **********************************************************************/

/*!
 * In-place radix-2 Cooley-Tukey FFT.
 *
 * Only power of two lengths are used here, so no other case is handled.
 * The inverse transform includes the 1/N scaling.
 *
 * \param a        data, transformed in place
 * \param inverse  true for the inverse transform
 */
void fft(std::vector<std::complex<float>> &a, bool inverse)
{
    const size_t n = a.size();
    if (n < 2) return;

    // Bit-reversal permutation.
    for (size_t i = 1, j = 0; i < n; i++)
    {
        size_t bit = n >> 1;
        for (; j & bit; bit >>= 1) j ^= bit;
        j ^= bit;
        if (i < j) std::swap(a[i], a[j]);
    }

    for (size_t len = 2; len <= n; len <<= 1)
    {
        const double ang = 2.0 * M_PI / double(len) * (inverse ? 1.0 : -1.0);
        const std::complex<float> wl(float(std::cos(ang)), float(std::sin(ang)));
        for (size_t i = 0; i < n; i += len)
        {
            std::complex<float> w(1.0f, 0.0f);
            for (size_t k = 0; k < len / 2; k++)
            {
                const std::complex<float> u = a[i + k];
                const std::complex<float> v = a[i + k + len / 2] * w;
                a[i + k] = u + v;
                a[i + k + len / 2] = u - v;
                w *= wl;
            }
        }
    }

    if (inverse)
        for (auto &x : a) x /= float(n);
}

/***********************************************************************
 * Synchronisation sequences (TS 38.211 7.4.2)
 **********************************************************************/

/*!
 * Generate the PSS sequence for one N_ID_2.
 *
 * d_PSS(n) = 1 - 2x(m), m = (n + 43 N_ID_2) mod 127, with x an m-sequence
 * from x(i+7) = (x(i+4) + x(i)) mod 2 seeded 0 1 1 0 1 1 1.
 *
 * \param nid2  0, 1 or 2
 * \return 127 values, each +1 or -1
 */
std::vector<float> pssSequence(int nid2)
{
    int x[N_SEQ];
    const int seed[7] = {0, 1, 1, 0, 1, 1, 1};
    for (int i = 0; i < 7; i++) x[i] = seed[i];
    for (int i = 0; i < N_SEQ - 7; i++) x[i + 7] = (x[i + 4] + x[i]) % 2;

    std::vector<float> d(N_SEQ);
    for (int n = 0; n < N_SEQ; n++)
        d[n] = 1.0f - 2.0f * float(x[(n + 43 * nid2) % N_SEQ]);
    return d;
}

/*!
 * Generate the SSS sequence for one cell identity.
 *
 * d_SSS(n) = [1 - 2x0((n+m0) mod 127)][1 - 2x1((n+m1) mod 127)] with
 * m0 = 15 floor(N_ID_1/112) + 5 N_ID_2 and m1 = N_ID_1 mod 112.
 *
 * \param nid1  0..335
 * \param nid2  0..2
 * \return 127 values, each +1 or -1
 */
std::vector<float> sssSequence(int nid1, int nid2)
{
    int x0[N_SEQ], x1[N_SEQ];
    for (int i = 0; i < 7; i++) { x0[i] = 0; x1[i] = 0; }
    x0[0] = 1; x1[0] = 1;
    for (int i = 0; i < N_SEQ - 7; i++)
    {
        x0[i + 7] = (x0[i + 4] + x0[i]) % 2;
        x1[i + 7] = (x1[i + 1] + x1[i]) % 2;
    }

    const int m0 = 15 * (nid1 / 112) + 5 * nid2;
    const int m1 = nid1 % 112;

    std::vector<float> d(N_SEQ);
    for (int n = 0; n < N_SEQ; n++)
        d[n] = (1.0f - 2.0f * float(x0[(n + m0) % N_SEQ])) *
               (1.0f - 2.0f * float(x1[(n + m1) % N_SEQ]));
    return d;
}

/*!
 * Build the time domain PSS reference for one N_ID_2.
 *
 * The 127 sequence values are placed on FFT bins -64..+62 and transformed
 * back to the time domain, giving one symbol body of samples to correlate
 * the received signal against.
 *
 * \param nid2  0, 1 or 2
 * \return N_FFT complex samples
 */
std::vector<std::complex<float>> pssTimeDomain(int nid2)
{
    const std::vector<float> d = pssSequence(nid2);

    std::vector<std::complex<float>> grid(N_FFT, std::complex<float>(0.0f, 0.0f));
    for (int n = 0; n < N_SEQ; n++)
    {
        const int bin = ((SEQ_BIN0 + n) % N_FFT + N_FFT) % N_FFT;
        grid[bin] = std::complex<float>(d[n], 0.0f);
    }

    fft(grid, true);
    return grid;
}

/***********************************************************************
 * Detection result
 **********************************************************************/

/*!
 * One decoded SSB.
 */
struct Detection
{
    long long sampleIndex = 0;   //!< absolute index of the PSS symbol body
    long long timeNs = 0;        //!< hardware timestamp of that sample
    int nid1 = -1;               //!< from the SSS
    int nid2 = -1;               //!< from the PSS
    int pci = -1;                //!< 3 * nid1 + nid2
    double pssCorr = 0.0;        //!< normalised PSS correlation, 0..1
    double sssMarginDb = 0.0;    //!< best SSS hypothesis over the runner-up
};

/***********************************************************************
 * Detector
 **********************************************************************/

/*!
 * Streaming SSB detector.
 *
 * Samples are pushed in as they arrive.  A frequency domain overlap-save
 * correlation searches for each of the three PSS waveforms; a normalised peak
 * above the threshold fixes the symbol timing and N_ID_2, after which the SSS
 * two symbols later is demodulated to recover N_ID_1.
 */
class Detector
{
public:
    /*!
     * Prepare the correlator.
     *
     * \param threshold  normalised PSS correlation required to accept a peak
     */
    explicit Detector(double threshold) : _threshold(threshold)
    {
        for (int i = 0; i < N_NID2; i++)
        {
            _pssTime[i] = pssTimeDomain(i);

            // Reference energy, for the normalised correlation metric.
            _pssEnergy[i] = 0.0;
            for (const auto &s : _pssTime[i]) _pssEnergy[i] += std::norm(s);

            // Zero padded conjugate spectrum, reused for every block.
            _pssFreq[i].assign(CORR_M, std::complex<float>(0.0f, 0.0f));
            std::copy(_pssTime[i].begin(), _pssTime[i].end(), _pssFreq[i].begin());
            fft(_pssFreq[i], false);
            for (auto &v : _pssFreq[i]) v = std::conj(v);
        }
    }

    /*!
     * Add newly received samples, along with the timestamp of the first one.
     *
     * \param buf     samples to append
     * \param n       how many
     * \param timeNs  hardware timestamp of buf[0]
     */
    void push(const std::complex<float> *buf, size_t n, long long timeNs)
    {
        _anchorIndex = _absStart + (long long)_buf.size();
        _anchorTimeNs = timeNs;
        _buf.insert(_buf.end(), buf, buf + n);
    }

    /*!
     * Best PSS correlation observed since the last call, and which N_ID_2
     * produced it.  Reported periodically so a user seeing no detections can
     * tell "nothing that looks like a PSS is present" from "the threshold is
     * slightly too high".
     *
     * \param nid2  receives the N_ID_2 of that peak, or -1
     * \return the peak normalised correlation, then resets the tracker
     */
    double takeBestSeen(int &nid2)
    {
        const double v = _bestSeen;
        nid2 = _bestSeenNid2;
        _bestSeen = 0.0;
        _bestSeenNid2 = -1;
        return v;
    }

    /*!
     * Process everything that can be processed, returning any SSBs found.
     *
     * A block is only consumed once enough samples exist beyond it to decode
     * a detection landing at its very end, so no peak is ever truncated.
     *
     * \param rate  sample rate, used to convert sample offsets to time
     * \return detections in ascending time order
     */
    std::vector<Detection> process(double rate)
    {
        std::vector<Detection> found;

        while ((long long)_buf.size() >= CORR_M + NEED_AHEAD)
        {
            correlateBlock();

            for (int off = 0; off + N_FFT <= CORR_M; off++)
            {
                const double metric = _metric[off];
                if (metric > _bestSeen)
                {
                    _bestSeen = metric;
                    _bestSeenNid2 = _bestNid2[off];
                }
                if (metric < _threshold) continue;

                // Keep only the local maximum of a run above threshold.
                if (off > 0 && _metric[off - 1] >= metric) continue;
                if (off + 1 < (int)_metric.size() && _metric[off + 1] > metric) continue;

                const long long abs = _absStart + off;
                if (abs - _lastDetection < SYM_STRIDE) continue;

                Detection d;
                d.sampleIndex = abs;
                d.nid2 = _bestNid2[off];
                d.pssCorr = metric;
                d.timeNs = _anchorTimeNs +
                           (long long)std::llround((abs - _anchorIndex) / rate * 1e9);

                if (decodeSss(off, d))
                {
                    d.pci = 3 * d.nid1 + d.nid2;
                    _lastDetection = abs;
                    found.push_back(d);
                }
            }

            _buf.erase(_buf.begin(), _buf.begin() + CORR_HOP);
            _absStart += CORR_HOP;
        }
        return found;
    }

private:
    /*!
     * Run the overlap-save correlation for one block, filling _metric and
     * _bestNid2 with the best normalised correlation at each offset.
     *
     * The metric is |corr|^2 / (E_window * E_reference), so it is 1.0 for a
     * perfect noiseless match and about 1/N_FFT for noise.
     */
    void correlateBlock()
    {
        _spec.assign(_buf.begin(), _buf.begin() + CORR_M);
        fft(_spec, false);

        _metric.assign(CORR_M, 0.0);
        _bestNid2.assign(CORR_M, -1);

        // Sliding energy of the N_FFT sample window at each offset.
        std::vector<double> energy(CORR_M + 1, 0.0);
        for (int i = 0; i < CORR_M; i++)
            energy[i + 1] = energy[i] + std::norm(_buf[i]);

        for (int id = 0; id < N_NID2; id++)
        {
            _work.resize(CORR_M);
            for (int i = 0; i < CORR_M; i++) _work[i] = _spec[i] * _pssFreq[id][i];
            fft(_work, true);

            for (int off = 0; off + N_FFT <= CORR_M; off++)
            {
                const double e = energy[off + N_FFT] - energy[off];
                if (e <= 0.0) continue;
                const double m = std::norm(_work[off]) / (e * _pssEnergy[id]);
                if (m > _metric[off]) { _metric[off] = m; _bestNid2[off] = id; }
            }
        }
    }

    /*!
     * Demodulate the SSS for a PSS peak and choose the best N_ID_1.
     *
     * The PSS and SSS symbols are transformed separately and the SSS is
     * divided by the PSS, sample by sample, which cancels the channel without
     * ever estimating it: the two symbols are 33 microseconds apart, so the
     * channel is effectively identical across them.  The product of the known
     * PSS and each candidate SSS sequence is then correlated against that
     * ratio, and the winner is taken.
     *
     * \param off  offset of the PSS symbol body within the buffer
     * \param d    detection to fill in, on success
     * \return true when a clear winner was found
     */
    bool decodeSss(int off, Detection &d)
    {
        std::vector<std::complex<float>> symPss(_buf.begin() + off,
                                                _buf.begin() + off + N_FFT);
        std::vector<std::complex<float>> symSss(_buf.begin() + off + SSS_OFFSET,
                                                _buf.begin() + off + SSS_OFFSET + N_FFT);
        fft(symPss, false);
        fft(symSss, false);

        // Ratio of SSS to PSS on each occupied subcarrier.
        std::vector<std::complex<float>> ratio(N_SEQ);
        for (int n = 0; n < N_SEQ; n++)
        {
            const int bin = ((SEQ_BIN0 + n) % N_FFT + N_FFT) % N_FFT;
            const std::complex<float> p = symPss[bin];
            const float pw = std::norm(p);
            ratio[n] = (pw > 0.0f) ? symSss[bin] * std::conj(p) / pw
                                   : std::complex<float>(0.0f, 0.0f);
        }

        const std::vector<float> pssRef = pssSequence(d.nid2);

        double best = -1.0, second = -1.0;
        int bestNid1 = -1;

        for (int nid1 = 0; nid1 < N_NID1; nid1++)
        {
            const std::vector<float> sssRef = sssSequence(nid1, d.nid2);
            std::complex<double> acc(0.0, 0.0);
            for (int n = 0; n < N_SEQ; n++)
                acc += std::complex<double>(ratio[n]) * double(pssRef[n] * sssRef[n]);

            const double m = std::abs(acc);
            if (m > best) { second = best; best = m; bestNid1 = nid1; }
            else if (m > second) { second = m; }
        }

        if (bestNid1 < 0 || second <= 0.0) return false;

        d.nid1 = bestNid1;
        d.sssMarginDb = 20.0 * std::log10(best / second);

        // A genuine SSS stands clearly above every other hypothesis.  Without
        // this, noise would still produce a "best" candidate every time.
        return d.sssMarginDb > 3.0;
    }

    double _threshold;

    std::vector<std::complex<float>> _pssTime[N_NID2];
    std::vector<std::complex<float>> _pssFreq[N_NID2];
    double _pssEnergy[N_NID2] = {0.0, 0.0, 0.0};

    std::vector<std::complex<float>> _buf;
    std::vector<std::complex<float>> _spec;
    std::vector<std::complex<float>> _work;
    std::vector<double> _metric;
    std::vector<int> _bestNid2;

    long long _absStart = 0;        //!< absolute index of _buf[0]
    long long _anchorIndex = 0;     //!< absolute index the timestamp refers to
    long long _anchorTimeNs = 0;    //!< hardware timestamp at _anchorIndex
    long long _lastDetection = -1000000;

    double _bestSeen = 0.0;         //!< peak correlation since last report
    int _bestSeenNid2 = -1;         //!< which N_ID_2 produced it
};

/***********************************************************************
 * Self test
 **********************************************************************/

/*!
 * Synthesise one SSB into a buffer.
 *
 * Only the PSS and SSS symbols carry anything; the two PBCH symbols are left
 * empty because the detector never looks at them.  Each symbol is mapped onto
 * the occupied bins, transformed to the time domain and given a cyclic prefix,
 * exactly as a real transmitter would.
 *
 * \param out    destination, written from \p at onwards
 * \param at     sample offset of the first cyclic prefix
 * \param nid1   N_ID_1 to encode
 * \param nid2   N_ID_2 to encode
 * \param amp    target time domain RMS of the SSB samples
 */
void synthesiseSsb(std::vector<std::complex<float>> &out, size_t at,
                   int nid1, int nid2, float amp)
{
    for (int sym = 0; sym < 4; sym++)
    {
        std::vector<float> seq;
        if (sym == 0)      seq = pssSequence(nid2);
        else if (sym == 2) seq = sssSequence(nid1, nid2);
        else               continue;              // PBCH symbols left empty

        std::vector<std::complex<float>> grid(N_FFT, std::complex<float>(0.0f, 0.0f));
        for (int n = 0; n < N_SEQ; n++)
        {
            const int bin = ((SEQ_BIN0 + n) % N_FFT + N_FFT) % N_FFT;
            grid[bin] = std::complex<float>(seq[n], 0.0f);
        }
        fft(grid, true);

        // Scale to a time domain RMS of amp.  Setting the amplitude on the
        // subcarriers instead would leave the transmitted symbol a factor of
        // 256/sqrt(127) smaller than intended, because the inverse transform
        // carries the 1/N.
        double rms = 0.0;
        for (const auto &v : grid) rms += std::norm(v);
        rms = std::sqrt(rms / double(N_FFT));
        const float scale = (rms > 0.0) ? float(amp / rms) : 0.0f;
        for (auto &v : grid) v *= scale;

        // Cyclic prefix is the tail of the symbol body, prepended.  Samples
        // are added rather than assigned so any existing content, such as the
        // noise in the self test, is preserved.
        const size_t base = at + size_t(sym) * SYM_STRIDE;
        for (int i = 0; i < N_CP; i++)
            if (base + i < out.size()) out[base + i] += grid[N_FFT - N_CP + i];
        for (int i = 0; i < N_FFT; i++)
            if (base + N_CP + i < out.size()) out[base + N_CP + i] += grid[i];
    }
}

/*!
 * Run the detector against a synthetic SSB of known identity.
 *
 * This exercises the sequence generation, the subcarrier mapping, the
 * correlator and the SSS decode without involving the radio at all, so a
 * failure here is a bug in this program rather than an RF problem.
 *
 * \param threshold  PSS threshold to use
 * \param snrDb      noise level relative to the SSB
 * \return true when every trial recovered the identity that was transmitted
 */
bool selfTest(double threshold, double snrDb)
{
    std::cout << "self test: synthetic SSB at " << snrDb << " dB SNR\n\n";
    std::cout << "     sent            detected          \n";
    std::cout << "  NID_1 NID_2  PCI   NID_1 NID_2  PCI    PSS    SSS     result\n";
    std::cout << "  ----- ----- ----   ----- ----- ----   -----  ------   ------\n";

    const int trials[][2] = {{0, 0}, {1, 1}, {2, 2}, {123, 0}, {335, 2}, {200, 1}};
    const double noise = std::pow(10.0, -snrDb / 20.0);

    unsigned seed = 12345;
    auto urand = [&seed]() {
        seed = seed * 1103515245u + 12345u;
        return float((seed >> 8) & 0xffff) / 65535.0f - 0.5f;
    };

    bool allOk = true;
    for (const auto &t : trials)
    {
        const int nid1 = t[0], nid2 = t[1];

        // A buffer long enough for the correlator to consume a whole block
        // plus the look-ahead it insists on before decoding.
        std::vector<std::complex<float>> sig(CORR_M + NEED_AHEAD + CORR_HOP);
        for (auto &x : sig)
            x = std::complex<float>(noise * urand(), noise * urand());

        synthesiseSsb(sig, 700, nid1, nid2, 1.0f);

        Detector det(threshold);
        det.push(sig.data(), sig.size(), 0);
        const auto found = det.process(7.68e6);

        const bool ok = (found.size() == 1 &&
                         found[0].nid1 == nid1 && found[0].nid2 == nid2);
        allOk = allOk && ok;

        std::cout << "  " << std::setw(5) << nid1 << " " << std::setw(5) << nid2
                  << " " << std::setw(4) << (3 * nid1 + nid2) << "   ";
        if (found.empty())
            std::cout << "    -     -     -       -       -    ";
        else
            std::cout << std::setw(5) << found[0].nid1 << " " << std::setw(5) << found[0].nid2
                      << " " << std::setw(4) << found[0].pci
                      << "   " << std::fixed << std::setw(5) << std::setprecision(3)
                      << found[0].pssCorr
                      << "  " << std::setw(5) << std::setprecision(1) << found[0].sssMarginDb
                      << " dB" << std::defaultfloat << "  ";
        std::cout << (ok ? " PASS" : " FAIL") << "\n";
    }

    std::cout << "\nself test " << (allOk ? "PASSED" : "FAILED") << "\n";
    return allOk;
}

/*!
 * Build one 20 ms transmit frame containing a single SSB.
 *
 * 20 ms is the default SSB period of a real cell, so the receiver sees the
 * same duty cycle it would see on air.
 *
 * \param rate  sample rate, which sets the frame length
 * \param pci   physical cell identity to encode
 * \param amp   sample amplitude
 * \return the frame, mostly zeros with one SSB near the start
 */
std::vector<std::complex<float>> buildSsbFrame(double rate, int pci, float amp)
{
    const size_t frameLen = size_t(rate * 0.02);
    std::vector<std::complex<float>> frame(frameLen, std::complex<float>(0.0f, 0.0f));
    synthesiseSsb(frame, 1000, pci / 3, pci % 3, amp);
    return frame;
}

/***********************************************************************
 * Command line
 **********************************************************************/

/*!
 * Print the command line reference.
 *
 * \param argv0  program name as invoked
 */
void usage(const char *argv0)
{
    std::cout <<
        "usage: " << argv0 << " --arfcn N --scs KHZ [options]\n"
        "\n"
        "  Detects 5G NR SSBs in real time and reports N_ID_1, N_ID_2, PCI and\n"
        "  the hardware timestamp of each one.  SISO, one receive channel.\n"
        "\n"
        "required:\n"
        "  --arfcn N      NR-ARFCN of the SSB centre frequency\n"
        "  --scs KHZ      SSB subcarrier spacing: 15, 30, 120 or 240\n"
        "\n"
        "options:\n"
        "  --device PATH  /dev/sdrN to use (default: first free board)\n"
        "  --gain DB      RX gain (default 40; lower it if the input clips)\n"
        "  --seconds S    stop after S seconds (default: run until Ctrl-C)\n"
        "  --threshold T  normalised PSS correlation to accept (default 0.08)\n"
        "  --repeat S     suppress repeats of the same PCI for S seconds\n"
        "                 (default 0: report every SSB, about 50 per second)\n"
        "  --dry-run      print the receiver plan and exit\n"
        "  --self-test    run the detector against a synthetic SSB and exit;\n"
        "                 needs no radio, and proves the DSP independently\n"
        "  --snr DB       SNR for the self test (default 10)\n"
        "  --loopback PCI transmit an SSB carrying this PCI while receiving, to\n"
        "                 prove the whole RF path.  KEYS THE TRANSMITTER.\n"
        "  --tx-gain DB   TX gain for --loopback (default 20)\n"
        "  --verbose      turn on SoapySDR debug logging\n"
        "\n"
        "example:\n"
        "  " << argv0 << " --arfcn 632628 --scs 30 --device /dev/sdr0 --gain 6\n";
}

} // anonymous namespace

/*!
 * Parse the command line, configure the receiver and run the detection loop
 * until the time limit or Ctrl-C.
 *
 * \return 0 if at least one SSB was detected, 1 otherwise
 */
int main(int argc, char **argv)
{
    Options o;

    for (int i = 1; i < argc; i++)
    {
        const std::string a = argv[i];
        auto next = [&]() -> std::string {
            if (i + 1 >= argc) throw std::runtime_error("missing value for " + a);
            return argv[++i];
        };
        try
        {
            if (a == "--help" || a == "-h") { usage(argv[0]); return 0; }
            else if (a == "--arfcn") o.arfcn = std::stoll(next());
            else if (a == "--scs") o.scsKhz = std::stod(next());
            else if (a == "--device") o.device = next();
            else if (a == "--gain") o.gain = std::stod(next());
            else if (a == "--seconds") o.seconds = std::stod(next());
            else if (a == "--threshold") o.threshold = std::stod(next());
            else if (a == "--repeat") o.repeatSec = std::stod(next());
            else if (a == "--dry-run") o.listOnly = true;
            else if (a == "--self-test") o.selfTest = true;
            else if (a == "--loopback") o.txPci = std::stoi(next());
            else if (a == "--tx-gain") o.txGain = std::stod(next());
            else if (a == "--snr") o.snrDb = std::stod(next());
            else if (a == "--verbose") o.verbose = true;
            else { std::cerr << "unknown option " << a << "\n\n"; usage(argv[0]); return 2; }
        }
        catch (const std::exception &ex)
        {
            std::cerr << "ERROR: " << ex.what() << "\n";
            return 2;
        }
    }

    if (o.selfTest) return selfTest(o.threshold, o.snrDb) ? 0 : 1;

    if (o.arfcn < 0) { std::cerr << "ERROR: --arfcn is required\n\n"; usage(argv[0]); return 2; }

    const double freq = arfcnToHz(o.arfcn);
    if (freq <= 0.0)
    {
        std::cerr << "ERROR: ARFCN " << o.arfcn << " is outside the NR raster\n";
        return 2;
    }

    const double scs = o.scsKhz * 1e3;
    if (o.scsKhz != 15 && o.scsKhz != 30 && o.scsKhz != 120 && o.scsKhz != 240)
    {
        std::cerr << "ERROR: --scs must be 15, 30, 120 or 240 kHz\n";
        return 2;
    }
    const double rate = double(N_FFT) * scs;

    std::cout << "detectssb - 5G NR SSB detection\n";
    std::cout << "  ARFCN         : " << o.arfcn << "\n";
    std::cout << "  SSB centre    : " << std::fixed << std::setprecision(3)
              << freq / 1e6 << " MHz\n";
    std::cout << "  SCS           : " << o.scsKhz << " kHz\n";
    std::cout << "  sample rate   : " << rate / 1e6 << " Msps  (" << N_FFT
              << " point FFT per symbol)\n";
    std::cout << "  PSS/SSS span  : " << N_SEQ * scs / 1e6 << " MHz on bins "
              << SEQ_BIN0 << " .. " << SEQ_BIN0 + N_SEQ - 1 << "\n";
    std::cout << "  symbol/CP     : " << N_FFT << " + " << N_CP
              << " = " << SYM_STRIDE << " samples, SSS at +" << SSS_OFFSET << "\n";
    std::cout << "  threshold     : " << std::setprecision(3) << o.threshold << "\n";
    std::cout << std::defaultfloat;

    if (o.listOnly) return 0;

    if (!o.verbose) SoapySDR::setLogLevel(SOAPY_SDR_WARNING);

    SoapySDR::Device *dev = nullptr;
    SoapySDR::Stream *stream = nullptr;
    int exitCode = 1;

    try
    {
        SoapySDR::Kwargs args;
        args["driver"] = "amarisoft";
        if (!o.device.empty()) args["device"] = o.device;
        dev = SoapySDR::Device::make(args);

        dev->setSampleRate(SOAPY_SDR_RX, 0, rate);
        dev->setFrequency(SOAPY_SDR_RX, 0, freq);
        dev->setGain(SOAPY_SDR_RX, 0, o.gain);

        std::cout << "  device        : "
                  << dev->getHardwareInfo()["device"] << " ("
                  << dev->getHardwareKey() << ")\n";
        std::cout << "  tuned         : " << std::fixed << std::setprecision(3)
                  << dev->getFrequency(SOAPY_SDR_RX, 0) / 1e6 << " MHz at "
                  << dev->getSampleRate(SOAPY_SDR_RX, 0) / 1e6 << " Msps, gain "
                  << dev->getGain(SOAPY_SDR_RX, 0) << " dB\n";
        std::cout << std::defaultfloat;

        // Loopback: bring TX up before RX, because enabling a direction the
        // hardware was not started with forces a restart of the device.
        SoapySDR::Stream *txStream = nullptr;
        std::vector<std::complex<float>> txFrame;
        size_t txPos = 0;
        if (o.txPci >= 0)
        {
            dev->setFrequency(SOAPY_SDR_TX, 0, freq);
            dev->setGain(SOAPY_SDR_TX, 0, o.txGain);
            txStream = dev->setupStream(SOAPY_SDR_TX, SOAPY_SDR_CF32, {0});
            dev->activateStream(txStream);
            txFrame = buildSsbFrame(rate, o.txPci, 0.3f);
            std::cout << "  loopback      : transmitting PCI " << o.txPci
                      << " (N_ID_1=" << o.txPci / 3 << " N_ID_2=" << o.txPci % 3
                      << ") at " << o.txGain << " dB every 20 ms\n";
        }

        stream = dev->setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, {0});
        const size_t mtu = dev->getStreamMTU(stream);
        dev->activateStream(stream);

        std::signal(SIGINT, onSignal);
        std::cout << "\nlistening (Ctrl-C to stop)\n\n";
        std::cout << "        time      timestamp(ns)   NID_2  NID_1   PCI   PSS    SSS\n";
        std::cout << "  ------------  ---------------   -----  -----  ----  -----  ------\n";

        Detector det(o.threshold);
        std::vector<std::complex<float>> buf(mtu * 2);
        void *buffs[1] = {buf.data()};

        const auto t0 = std::chrono::steady_clock::now();
        size_t totalSamples = 0;
        size_t detections = 0;
        int lastPci = -1;
        double lastPciTime = -1e9;
        double nextStatus = 1.0;
        size_t txWritten = 0, txErrors = 0;
        double rxPowerAcc = 0.0; size_t rxPowerN = 0;

        while (g_stop == 0)
        {
            if (txStream != nullptr)
            {
                // Feed the transmitter a chunk of the frame, wrapping around.
                std::vector<std::complex<float>> chunk(mtu);
                for (size_t i = 0; i < mtu; i++)
                    chunk[i] = txFrame[(txPos + i) % txFrame.size()];
                txPos = (txPos + mtu) % txFrame.size();
                const void *txBuffs[1] = {chunk.data()};
                int txFlags = 0;
                const int wr = dev->writeStream(txStream, txBuffs, mtu, txFlags, 0, 100000);
                if (wr < 0) txErrors++; else txWritten += size_t(wr);
            }

            int flags = 0;
            long long timeNs = 0;
            const int ret = dev->readStream(stream, buffs, mtu, flags, timeNs, 1000000);
            if (ret == SOAPY_SDR_TIMEOUT) continue;
            if (ret < 0)
            {
                std::cerr << "readStream error " << ret << " ("
                          << SoapySDR::errToStr(ret) << ")\n";
                break;
            }

            totalSamples += size_t(ret);
            for (int i = 0; i < ret; i++) rxPowerAcc += std::norm(buf[i]);
            rxPowerN += size_t(ret);
            det.push(buf.data(), size_t(ret), timeNs);

            for (const auto &d : det.process(rate))
            {
                const double t = std::chrono::duration<double>(
                    std::chrono::steady_clock::now() - t0).count();

                if (o.repeatSec > 0.0 && d.pci == lastPci &&
                    t - lastPciTime < o.repeatSec)
                    continue;
                lastPci = d.pci;
                lastPciTime = t;

                detections++;
                std::cout << "  " << std::fixed << std::setw(10) << std::setprecision(4) << t
                          << "  " << std::setw(15) << d.timeNs
                          << "   " << std::setw(5) << d.nid2
                          << "  " << std::setw(5) << d.nid1
                          << "  " << std::setw(4) << d.pci
                          << "  " << std::setw(5) << std::setprecision(3) << d.pssCorr
                          << "  " << std::setw(5) << std::setprecision(1) << d.sssMarginDb
                          << " dB" << std::defaultfloat << "\n";
                std::cout.flush();
            }

            const double now = std::chrono::duration<double>(
                std::chrono::steady_clock::now() - t0).count();

            // Periodic diagnostic: what is the strongest thing out there that
            // looks like a PSS, whether or not it cleared the threshold?
            if (now >= nextStatus)
            {
                nextStatus = now + 1.0;
                int bn = -1;
                const double best = det.takeBestSeen(bn);
                if (detections == 0)
                {
                    std::cout << "  " << std::fixed << std::setw(10) << std::setprecision(4)
                              << now << "  (no SSB yet; best PSS correlation "
                              << std::setprecision(4) << best;
                    if (bn >= 0) std::cout << " on N_ID_2=" << bn;
                    const double rxDb = 10.0 * std::log10(
                        (rxPowerN ? rxPowerAcc / double(rxPowerN) : 0.0) + 1e-30);
                    std::cout << ", threshold " << std::setprecision(3) << o.threshold
                              << ", RX " << std::setprecision(1) << rxDb << " dBFS";
                    if (txStream != nullptr)
                        std::cout << ", TX " << txWritten << " samples/" << txErrors << " err";
                    std::cout << ")" << std::defaultfloat << "\n";
                    std::cout.flush();
                }
                rxPowerAcc = 0.0; rxPowerN = 0;
            }

            if (o.seconds > 0.0 && now >= o.seconds) break;
        }

        const double wall = std::chrono::duration<double>(
            std::chrono::steady_clock::now() - t0).count();

        std::cout << "\n--- summary ---\n";
        std::cout << "  detections    : " << detections << "\n";
        std::cout << "  samples       : " << totalSamples << " in " << wall << " s\n";
        std::cout << "  throughput    : " << (wall > 0 ? totalSamples / wall / 1e6 : 0.0)
                  << " Msps (real time needs " << rate / 1e6 << ")\n";
        std::cout << "  rx overflows  : " << dev->readSensor("rx_overflow_count") << "\n";

        if (wall > 0 && totalSamples / wall < rate * 0.95)
            std::cout << "  NOTE: the detector did not keep up with the radio;\n"
                         "        samples were dropped and SSBs may have been missed\n";

        exitCode = (detections > 0) ? 0 : 1;
    }
    catch (const std::exception &ex)
    {
        std::cerr << "ERROR: " << ex.what() << "\n";
        exitCode = 1;
    }

    if (dev != nullptr && stream != nullptr)
    {
        dev->deactivateStream(stream);
        dev->closeStream(stream);
    }
    if (dev != nullptr) SoapySDR::Device::unmake(dev);

    return exitCode;
}

NOTE : One detail in 'synthesiseSsb' is worth singling out because it is a mistake that is easy to make and hard to see. The sequence values are placed on the subcarriers and then inverse transformed, and that transform carries the 1/N scaling, so setting the amplitude on the subcarriers would leave the transmitted symbol a factor of 256/sqrt(127) smaller than intended - about 27 dB. The symbol is therefore rescaled to a target time domain RMS after the transform. The first version of this program did not do that, and the result was a loopback signal far too weak to detect, with nothing obviously wrong in the code to point at.

Using the Module from Python

The support module is written in C++, but nothing about it is tied to C++. It registers itself with the SoapySDR runtime, and every language binding sits on top of that same runtime, so a Python application reaches the Amarisoft card through exactly the same code path a C++ one does. The module is not rebuilt, reconfigured or even aware that the caller is Python.

Only the bindings need installing. They are packaged, and the version must match the SoapySDR already installed, which on this system is 0.7.1.

sudo dnf install python3-SoapySDR

A three line check confirms the runtime, the module and the hardware are all visible from Python. No radio is opened, so this is safe to run at any time.

python3 -c "

import SoapySDR

print('API', SoapySDR.getAPIVersion())

for d in SoapySDR.Device.enumerate(dict(driver='amarisoft')): print(dict(d))

"

API 0.7.1
{'device': '/dev/sdr0', 'driver': 'amarisoft', 'label': 'Amarisoft SDR /dev/sdr0'}
{'device': '/dev/sdr1', 'driver': 'amarisoft', 'label': 'Amarisoft SDR /dev/sdr1'}
{'device': '/dev/sdr2', 'driver': 'amarisoft', 'label': 'Amarisoft SDR /dev/sdr2'}
{'device': '/dev/sdr3', 'driver': 'amarisoft', 'label': 'Amarisoft SDR /dev/sdr3'}

Two examples follow. The first shows that the streaming interface itself works from Python at the full sample rate. The second repeats the SSB detection of the previous section in Python, and is included because the result is not the same - it shows where the practical limit of a Python receiver lies.

Example 1 - a minimal receiver

This is the Python counterpart of the minimal C++ application, and it makes the same sequence of calls in the same order. Those calls are highlighted in the listing: construct the device, set up a stream, activate it, read from it while checking the return value, then deactivate and close. The exception handler is highlighted with them because it is equally required - constructing the device raises if the board is already held by another process.

Two details in the read loop are easy to get wrong and are worth pointing out. A return of SOAPY_SDR_TIMEOUT is an ordinary event rather than an error, so the loop continues rather than aborting. And the return value is the number of samples actually delivered, which is one DMA hyperframe and not necessarily the number requested, so the buffer is sliced to that length before use. The teardown lives in a 'finally' block for the same reason the C++ version uses destructors: the card is opened exclusively, and a program that exits without releasing it leaves the device held.

Because the program is a single straight path, its diagram is simply the order of the calls. The two boxes on the left are the return values that look like failures and are not.

main() the mandatory sequence SoapySDR.Device(args) open - raises if the board is busy setSampleRate / setFrequency / setGain configure the front end setupStream(RX, CF32, [0]) declare the stream activateStream(rx) start it readStream(rx, [buf], mtu) loop - check the return value deactivateStream / closeStream in finally, so it always runs ret == TIMEOUT normal - continue ret < mtu normal - one hyperframe repeat until enough samples no Amarisoft header and no libsdr symbol: every call goes through SoapySDR

Following is the entire source code. Try to get the overall structure of the code shown above and then look into the code to find the details. Or just copy the entire code into an AI agent (e.g, Claude Code , Codex etc) and ask the details

#!/usr/bin/env python3
"""
The smallest *correct* SoapySDR receiver for the Amarisoft card, in Python.

Same mandatory sequence as the C++ version: open, set up a stream, activate it,
read while checking the return, then deactivate, close and release.  Nothing in
this file is Amarisoft specific except the driver name.

  ./rx_minimal.py --device /dev/sdr0 --freq 3479.52e6 --rate 7.68e6 --gain 6
"""

import argparse
import sys
import time

import numpy as np
import SoapySDR
from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CF32, SOAPY_SDR_TIMEOUT


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--device")                              # optional
    ap.add_argument("--freq", type=float, default=3479.52e6)
    ap.add_argument("--rate", type=float, default=7.68e6)
    ap.add_argument("--gain", type=float, default=6.0)
    ap.add_argument("--seconds", type=float, default=2.0)
    a = ap.parse_args()

    args = dict(driver="amarisoft")                          # required
    if a.device:
        args["device"] = a.device

    dev = None
    rx = None
    try:
        dev = SoapySDR.Device(args)                          # required

        dev.setSampleRate(SOAPY_SDR_RX, 0, a.rate)
        dev.setFrequency(SOAPY_SDR_RX, 0, a.freq)
        dev.setGain(SOAPY_SDR_RX, 0, a.gain)

        rx = dev.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, [0])   # required
        dev.activateStream(rx)                                    # required

        mtu = dev.getStreamMTU(rx)
        buf = np.empty(mtu, np.complex64)

        print(f"tuned {dev.getFrequency(SOAPY_SDR_RX, 0)/1e6:.3f} MHz at "
              f"{dev.getSampleRate(SOAPY_SDR_RX, 0)/1e6:.3f} Msps, "
              f"gain {dev.getGain(SOAPY_SDR_RX, 0)} dB, MTU {mtu}")

        target = int(a.rate * a.seconds)
        got = 0
        timeouts = 0
        power = 0.0
        t0 = time.monotonic()

        while got < target:
            sr = dev.readStream(rx, [buf], mtu)               # required
            if sr.ret == SOAPY_SDR_TIMEOUT:                   # normal, not fatal
                timeouts += 1
                continue
            if sr.ret < 0:                                    # a real error
                print(f"readStream error {sr.ret}", file=sys.stderr)
                break
            n = sr.ret                                        # may be < mtu
            power += float(np.sum(np.abs(buf[:n]) ** 2))
            got += n

        wall = time.monotonic() - t0
        print(f"read {got} samples in {wall:.3f} s "
              f"= {got/wall/1e6:.3f} Msps (real time needs {a.rate/1e6:.3f})")
        print(f"mean power {10*np.log10(power/max(got,1) + 1e-30):.2f} dBFS, "
              f"timeouts {timeouts}, overflows {dev.readSensor('rx_overflow_count')}")

    except Exception as ex:                                   # Device() throws
        print(f"ERROR: {ex}", file=sys.stderr)
        return 1
    finally:
        # Required: the card is opened exclusively.
        if dev is not None and rx is not None:
            dev.deactivateStream(rx)
            dev.closeStream(rx)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Running it against the live cell gives the full sample rate with nothing dropped.

[root@UESB-2021102500 python]# ./rx_minimal.py --device /dev/sdr0 --freq 3479.52e6 --rate 7.68e6 --gain 6 --seconds 2
tuned 3479.520 MHz at 7.680 Msps, gain 6.0 dB, MTU 512
read 15360000 samples in 2.000 s = 7.681 Msps (real time needs 7.680)
mean power -39.69 dBFS, timeouts 0, overflows 0

7.681 Msps against a configured 7.680, no timeouts and no overflows. For capture, logging, power measurement or any work that processes a block at a time, Python is perfectly adequate.

Example 2 - SSB detection, and where Python runs out

The second example is a numpy port of the SSB detector from the previous section, kept deliberately close to the C++ so the two can be compared line for line. It performs the same overlap-save PSS correlation and the same SSS decode, with the inner loops expressed as array operations: the three correlations are a single batched multiply and inverse transform, the sliding window energy is a cumulative sum, and all three hundred and thirty six SSS hypotheses are tested with one matrix multiply.

The shape is the same as the C++ version, which is the point of the comparison: the same three stages in the same order, with the inner loops replaced by array operations.

sequence generators - TS 38.211, identical to the C++ class Detector - the numpy port of the C++ detector main() configure, stream, report arfcn_to_hz() TS 38.104 raster SoapySDR.Device stream RX at 256 x SCS Detector(threshold) build PSS references numpy fft, cumsum push() append samples process() per 2048 block _decode() SSS hypotheses batched ifft x3 the PSS correlation cumsum + matmul (336 x 127) energy and hypotheses pss_seq() sss_seq() occupied_bins() same algorithm as detectssb.cpp; falls short of real time at 7.68 Msps

Following is the entire source code. Try to get the overall structure of the code shown above and then look into the code to find the details. Or just copy the entire code into an AI agent (e.g, Claude Code , Codex etc) and ask the details

#!/usr/bin/env python3
"""
5G NR SSB detection in Python, over SoapySDR.

A numpy port of detectssb.cpp, kept deliberately close to it so the two can be
compared.  The point is to show that the SoapySDR abstraction is fast enough to
support real signal processing from Python, not only from C++.

  ./detect_ssb.py --arfcn 631968 --scs 30 --device /dev/sdr0 --gain 6
"""

import argparse
import sys
import time

import numpy as np
import SoapySDR
from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CF32, SOAPY_SDR_TIMEOUT

N_FFT = 256                        # one OFDM symbol at 256 x SCS
N_CP = 144 * N_FFT // 2048         # 18
SYM = N_FFT + N_CP                 # 274
SSS_OFF = 2 * SYM                  # 548
N_SEQ = 127
BIN0 = -64
NEED_AHEAD = SSS_OFF + N_FFT       # 804
M = 2048                           # overlap-save block
HOP = M - N_FFT + 1                # 1793


def arfcn_to_hz(n):
    """NR-ARFCN to Hz, TS 38.104 5.4.2.1."""
    if n <= 599999:
        return 5e3 * n
    if n <= 2016666:
        return 3000e6 + 15e3 * (n - 600000)
    return 24250.08e6 + 60e3 * (n - 2016667)


def pss_seq(nid2):
    """PSS sequence for one N_ID_2, TS 38.211 7.4.2.2."""
    x = np.zeros(N_SEQ, np.int8)
    x[:7] = [0, 1, 1, 0, 1, 1, 1]
    for i in range(N_SEQ - 7):
        x[i + 7] = (x[i + 4] + x[i]) % 2
    n = np.arange(N_SEQ)
    return 1.0 - 2.0 * x[(n + 43 * nid2) % N_SEQ]


def sss_seq(nid1, nid2):
    """SSS sequence for one cell identity, TS 38.211 7.4.2.3."""
    x0 = np.zeros(N_SEQ, np.int8); x0[0] = 1
    x1 = np.zeros(N_SEQ, np.int8); x1[0] = 1
    for i in range(N_SEQ - 7):
        x0[i + 7] = (x0[i + 4] + x0[i]) % 2
        x1[i + 7] = (x1[i + 1] + x1[i]) % 2
    m0 = 15 * (nid1 // 112) + 5 * nid2
    m1 = nid1 % 112
    n = np.arange(N_SEQ)
    return (1.0 - 2.0 * x0[(n + m0) % N_SEQ]) * (1.0 - 2.0 * x1[(n + m1) % N_SEQ])


def occupied_bins():
    """FFT bins carrying PSS/SSS."""
    return (np.arange(N_SEQ) + BIN0) % N_FFT


def pss_time(nid2):
    """Time domain PSS reference: sequence on its bins, inverse transformed."""
    grid = np.zeros(N_FFT, np.complex128)
    grid[occupied_bins()] = pss_seq(nid2)
    return np.fft.ifft(grid)


class Detector:
    """PSS correlation and SSS decode, vectorised with numpy."""

    def __init__(self, threshold):
        self.threshold = threshold
        self.ref_f = []
        self.ref_e = []
        for nid2 in range(3):
            t = pss_time(nid2)
            self.ref_e.append(float(np.sum(np.abs(t) ** 2)))
            padded = np.zeros(M, np.complex128)
            padded[:N_FFT] = t
            self.ref_f.append(np.conj(np.fft.fft(padded)))
        self.ref_f = np.array(self.ref_f)                 # (3, M)
        self.ref_e = np.array(self.ref_e)                 # (3,)
        self.sss_tab = {n2: np.array([sss_seq(n1, n2) for n1 in range(336)])
                        for n2 in range(3)}               # (336, 127) each
        self.buf = np.zeros(0, np.complex64)
        self.abs_start = 0
        self.anchor_idx = 0
        self.anchor_ns = 0
        self.last_det = -10 ** 9
        self.best_seen = 0.0

    def push(self, samples, time_ns):
        self.anchor_idx = self.abs_start + len(self.buf)
        self.anchor_ns = time_ns
        self.buf = np.concatenate([self.buf, samples])

    def process(self, rate):
        out = []
        while len(self.buf) >= M + NEED_AHEAD:
            blk = self.buf[:M].astype(np.complex128)
            spec = np.fft.fft(blk)
            corr = np.fft.ifft(spec[None, :] * self.ref_f, axis=1)   # (3, M)

            # sliding N_FFT energy of the block, via a cumulative sum
            cs = np.concatenate([[0.0], np.cumsum(np.abs(blk) ** 2)])
            nvalid = M - N_FFT + 1
            e_win = cs[N_FFT:N_FFT + nvalid] - cs[:nvalid]
            e_win[e_win <= 0] = np.inf

            metric = (np.abs(corr[:, :nvalid]) ** 2
                      / (e_win[None, :] * self.ref_e[:, None]))      # (3, nvalid)
            best_n2 = np.argmax(metric, axis=0)
            best_m = metric[best_n2, np.arange(nvalid)]
            self.best_seen = max(self.best_seen, float(best_m.max()))

            for off in np.flatnonzero(best_m >= self.threshold):
                if off > 0 and best_m[off - 1] >= best_m[off]:
                    continue
                if off + 1 < nvalid and best_m[off + 1] > best_m[off]:
                    continue
                a = self.abs_start + int(off)
                if a - self.last_det < SYM:
                    continue
                d = self._decode(int(off), int(best_n2[off]), float(best_m[off]), a, rate)
                if d:
                    self.last_det = a
                    out.append(d)

            self.buf = self.buf[HOP:]
            self.abs_start += HOP
        return out

    def _decode(self, off, nid2, corr, abs_idx, rate):
        """Divide SSS by PSS to cancel the channel, then test all 336 N_ID_1."""
        bins = occupied_bins()
        p = np.fft.fft(self.buf[off:off + N_FFT].astype(np.complex128))[bins]
        s = np.fft.fft(self.buf[off + SSS_OFF:off + SSS_OFF + N_FFT]
                       .astype(np.complex128))[bins]
        pw = np.abs(p) ** 2
        ratio = np.where(pw > 0, s * np.conj(p) / np.where(pw > 0, pw, 1), 0)

        scores = np.abs((ratio * pss_seq(nid2))[None, :] @ self.sss_tab[nid2].T)[0]
        order = np.argsort(scores)[::-1]
        best, second = scores[order[0]], scores[order[1]]
        if second <= 0:
            return None
        margin = 20 * np.log10(best / second)
        if margin <= 3.0:
            return None
        nid1 = int(order[0])
        return dict(nid1=nid1, nid2=nid2, pci=3 * nid1 + nid2, corr=corr,
                    margin=float(margin),
                    time_ns=int(self.anchor_ns
                                + round((abs_idx - self.anchor_idx) / rate * 1e9)))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--arfcn", type=int, required=True)
    ap.add_argument("--scs", type=float, default=30.0)
    ap.add_argument("--device")
    ap.add_argument("--gain", type=float, default=40.0)
    ap.add_argument("--seconds", type=float, default=2.0)
    ap.add_argument("--threshold", type=float, default=0.08)
    a = ap.parse_args()

    freq = arfcn_to_hz(a.arfcn)
    rate = N_FFT * a.scs * 1e3

    print("detect_ssb.py - 5G NR SSB detection (Python / numpy)")
    print(f"  ARFCN         : {a.arfcn}")
    print(f"  SSB centre    : {freq/1e6:.3f} MHz")
    print(f"  SCS           : {a.scs} kHz")
    print(f"  sample rate   : {rate/1e6:.3f} Msps")

    args = dict(driver="amarisoft")
    if a.device:
        args["device"] = a.device

    dev = None
    rx = None
    try:
        dev = SoapySDR.Device(args)
        dev.setSampleRate(SOAPY_SDR_RX, 0, rate)
        dev.setFrequency(SOAPY_SDR_RX, 0, freq)
        dev.setGain(SOAPY_SDR_RX, 0, a.gain)

        rx = dev.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, [0])
        dev.activateStream(rx)
        mtu = dev.getStreamMTU(rx)
        buf = np.empty(mtu, np.complex64)

        det = Detector(a.threshold)
        print(f"\n{'time':>12}  {'timestamp(ns)':>15}   NID_2  NID_1   PCI   PSS    SSS")
        print(f"  {'-'*12}  {'-'*15}   -----  -----  ----  -----  ------")

        target = int(rate * a.seconds)
        got = 0
        ndet = 0
        t0 = time.monotonic()
        while got < target:
            sr = dev.readStream(rx, [buf], mtu)
            if sr.ret == SOAPY_SDR_TIMEOUT:
                continue
            if sr.ret < 0:
                print(f"readStream error {sr.ret}", file=sys.stderr)
                break
            got += sr.ret
            det.push(buf[:sr.ret].copy(), sr.timeNs)
            for d in det.process(rate):
                ndet += 1
                t = time.monotonic() - t0
                print(f"  {t:10.4f}  {d['time_ns']:15d}   {d['nid2']:5d}  "
                      f"{d['nid1']:5d}  {d['pci']:4d}  {d['corr']:5.3f}  "
                      f"{d['margin']:5.1f} dB")

        wall = time.monotonic() - t0
        print("\n--- summary ---")
        print(f"  detections    : {ndet}")
        print(f"  samples       : {got} in {wall:.3f} s")
        print(f"  throughput    : {got/wall/1e6:.3f} Msps (real time needs {rate/1e6:.3f})")
        print(f"  rx overflows  : {dev.readSensor('rx_overflow_count')}")
        print(f"  best PSS corr : {det.best_seen:.4f}")
        if got / wall < rate * 0.95:
            print("  NOTE: did not keep up with the radio; SSBs may have been missed")
        return 0 if ndet else 1
    except Exception as ex:
        print(f"ERROR: {ex}", file=sys.stderr)
        return 1
    finally:
        if dev is not None and rx is not None:
            dev.deactivateStream(rx)
            dev.closeStream(rx)


if __name__ == "__main__":
    sys.exit(main())

The detection itself is correct. It reports the same cell identity as the C++ program, with the same correlation and the same SSS margin.

[root@UESB-2021102500 python]# ./detect_ssb.py --arfcn 631968 --scs 30 --device /dev/sdr0 --gain 6 --seconds 1
detect_ssb.py - 5G NR SSB detection (Python / numpy)
  ARFCN         : 631968
  SSB centre    : 3479.520 MHz
  SCS           : 30.0 kHz
  sample rate   : 7.680 Msps

        time    timestamp(ns)   NID_2  NID_1   PCI   PSS    SSS
  ------------  ---------------   -----  -----  ----  -----  ------
      0.0131        331835808       2    166   500  0.999   16.9 dB
      0.0401        351835807       2    166   500  0.999   17.1 dB
      0.0541        371835808       2    166   500  0.999   17.1 dB
      0.0810        391835807       2    166   500  0.998   17.1 dB
      0.0946        411835807       2    166   500  0.997   17.1 dB
      0.1214        431835808       2    166   500  0.997   17.2 dB
      ...            ...           ...    ...    ...    ...     ...
      1.3360       1651835677       2    166   500  0.851   17.2 dB

--- summary ---
  detections    : 67
  samples       : 7680000 in 1.339 s
  throughput    : 5.736 Msps (real time needs 7.680)
  rx overflows  : 34
  best PSS corr : 0.9987
  NOTE: did not keep up with the radio; SSBs may have been missed

The summary is where the two part company. Throughput is 5.736 Msps against a configured 7.680, the receive overflow counter has reached thirty four, and the program prints its own warning. Because the correlator cannot consume samples as fast as the radio produces them, the hardware discards the excess, and the SSBs carried in those discarded samples are simply never seen. In one second the C++ program reports fifty detections, one for every SSB; this one reports sixty seven across 1.34 seconds, which is well short of the seventy the period implies.

                          C++ detectssb      Python detect_ssb.py
  throughput              7.680 Msps         5.736 Msps
  rx overflows            0                  34
  detections per second   50  (every SSB)    50 falling to ~43
  cell identity           PCI 500            PCI 500       (identical)
  PSS correlation         0.99               0.99          (identical)

NOTE : This is a statement about this particular workload, not about Python. The gap is small, and the same detector would comfortably reach real time at a lower sample rate - 15 kHz subcarrier spacing halves it to 3.84 Msps - or with a decimated PSS search, or with a faster transform such as pyfftw in place of numpy. What the comparison does show is where the boundary sits: the SoapySDR streaming interface is not the bottleneck in either language, and the module delivered the same samples to both.

The practical conclusion is that Python is a good choice for controlling the radio and for any analysis that can be done a block at a time, while sustained sample rate signal processing is better placed in a compiled language. Both reach the same hardware through the same module, so the two can be mixed freely.

Installing/Uninstalling soapy_project package

Everything described in this tutorial - the support module, the two C++ programs, the Python examples and the probe programs - lives in a single directory tree called 'soapy_project'. This section covers moving that tree to another machine, building it there, and removing it again afterwards.

One rule governs the whole process: build on the machine that will run it. The support module links the Amarisoft libsdr and records that library's location as an RPATH at build time, so a binary compiled elsewhere will not resolve unless trx_sdr happens to sit at the identical path. The module is also loaded only by a SoapySDR runtime with a matching ABI. Both problems disappear if the source is shipped and compiled in place, which is what the scripts below do.

Manual Installation

Download the package here and copy it into '/root' on the machine that will run it. The archive is small, a little under eighty kilobytes, because it holds source only and no build output. It expands into a single directory called 'soapy_project'.

[root@target ~]# cd /root

[root@target ~]# tar xzf soapy-project-pack-20260826.tar.gz

[root@target ~]# cd soapy_project

Then run 'install.sh'. It checks every dependency, installs the ones that are missing, builds the three CMake projects in the order they depend on each other, and finishes by confirming that the SoapySDR runtime has actually registered the factory. That last step matters: a module can compile and install successfully and still fail to load, and the build reports success either way.

[root@target soapy_project]# bash install.sh

Two other modes are available. '--check' reports what is missing and stops, installing and building nothing, which is the safe way to find out whether a machine is ready. '--no-deps' builds but never touches the package manager, for a machine whose packages are managed elsewhere.

bash install.sh --check # report only; install and build nothing

bash install.sh --no-deps # build only; never touch the package manager

# if trx_sdr is not at the default /root/trx_sdr:

TRX_SDR_ROOT=/root/trx_sdr-linux-2026-08-18 bash install.sh

The output below is a real installation onto a machine that had never seen the package. Note that it differed from the development machine in several ways at once - a later Fedora, a later kernel, a later SoapySDR point release and half the number of SDR devices - and that nothing needed adjusting for any of it.

=== dependencies ===
  [ ok ] SoapySDR runtime
  [ -- ] SoapySDR headers  (package: SoapySDR-devel)
  [ ok ] cmake
  [ ok ] C++ compiler
  [ ok ] make
  [ -- ] python3 SoapySDR bindings (for python/)  (package: python3-SoapySDR)
  [ ok ] python3 numpy (for python/)

=== installing: SoapySDR-devel python3-SoapySDR ===
  ... dnf output ...

=== re-checking ===
  [ ok ] SoapySDR headers
  [ ok ] python3 SoapySDR

=== SoapySDR version ===
  [ ok ] API v0.7.1, ABI v0.7 (the version this tree was tested against)

=== Amarisoft components ===
  [ ok ] trx_sdr SDK at /root/trx_sdr
  [ ok ] device nodes: /dev/sdr0 /dev/sdr1

=== building ===
  SoapyAmarisoft   ok
  hellosoapy       ok
  detectssb        ok

=== verify ===
  Module found: /usr/local/lib64/SoapySDR/modules0.7/libamarisoftSupport.so
  Available factories... amarisoft

The obvious next step is to prove the installation against real hardware, which is what 'hellosoapy' is for. On that same machine it reported eleven checks passed, including the two transmit checks.

[root@target soapy_project]# ./hellosoapy/build/hellosoapy --tx

-> PASS: 1. Module loaded and factory registered
-> PASS: 2. Device enumeration
   ...
-> PASS: 11. Out-of-range frequency guard
  passed: 11   failed: 0   skipped: 0   device: /dev/sdr0

The package was produced from a working tree by 'make_tarball.sh', which is included in it. If you modify the sources and want to move them to another machine, run that script and a dated archive appears alongside them. It excludes the build directories deliberately, because CMake writes absolute paths into its cache and a build tree carried to a different location refers to directories that no longer exist, failing in a way that is hard to read.

NOTE : Only the Amarisoft parts cannot be installed automatically. The trx_sdr SDK is part of the licensed release, so 'install.sh' stops and tells you how to point at it; the 'sdr' kernel module has to be loaded by the Amarisoft installation, and its absence produces a warning rather than an error, because the tree still builds without hardware present.

Install with AI Agent

The scripts were written so that a coding agent with shell access can carry out the whole procedure without further instruction, which is worth mentioning because it is how this package was developed and tested in the first place. Given the tarball and an SSH login, an agent such as Claude Code can copy it across, install it, run the checks and report back.

Copy soapy-project-pack-20260826.tar.gz to root@10.0.0.185 (password: toor),

install it under /root, and run hellosoapy to confirm it works.

Three properties of the scripts make this work reliably rather than by luck. Every failure message names the thing to fix rather than merely reporting that something went wrong, so an agent can act on it instead of guessing. Every step has a non-destructive form - 'install.sh --check' and 'uninstall.sh --dry-run' - which lets an agent establish the state of a machine before changing it. And every script ends by verifying the result independently of the step that produced it, so success is confirmed rather than assumed.

The same properties help a human, and the workflow is worth using in reverse as well: when something does go wrong, the build logs are left in '/tmp/build_*.log' and the checks can be re-run individually, which gives an agent enough to diagnose the problem rather than simply reporting a failure.

NOTE : An agent asked to install software will modify the machine, so the usual care applies. Give it a specific target rather than a general instruction, prefer '--check' first on a machine you care about, and remember that 'install.sh' installs packages with the system package manager. Nothing in this package touches the Amarisoft installation itself, but that is a property of these scripts rather than a guarantee about agents in general.

Uninstalling the package

'uninstall.sh' reverses what 'install.sh' did. By default it removes the installed module and the three build directories, and leaves the packages alone.

[root@target ~]# cd /root/soapy_project

[root@target soapy_project]# bash uninstall.sh

=== installed module ===
  removing: /usr/local/lib64/SoapySDR/modules0.7/libamarisoftSupport.so

=== build directories ===
  removing: /root/soapy_project/SoapyAmarisoft/build
  removing: /root/soapy_project/hellosoapy/build
  removing: /root/soapy_project/detectssb/build

=== packages ===
  install.sh added: SoapySDR-devel python3-SoapySDR
  left in place.  Re-run with --all to remove them.

=== verify ===
  the amarisoft factory is gone from SoapySDR

The source tree itself is untouched.  Remove it with:
  rm -rf /root/soapy_project

The file to remove is taken from 'install_manifest.txt', which CMake writes at install time listing exactly what it placed on the system. That is more dependable than guessing a path, and it is why the build directories are removed last rather than first. If the build tree has already been deleted the script falls back to searching the SoapySDR module directories by name.

Packages are treated more carefully than files. 'install.sh' records the packages it installed in a file named '.installed_packages', and only those are ever candidates for removal - anything that was already present on the machine is left alone. On the machine above that distinction mattered: the SoapySDR runtime had been installed months earlier and was in use by other software, while 'SoapySDR-devel' and 'python3-SoapySDR' were added by the install and are safe to take away again.

bash uninstall.sh --dry-run # show what would be removed, remove nothing

bash uninstall.sh --all # also remove the packages install.sh added

rm -rf /root/soapy_project # finally, the source tree itself

The script finishes by asking SoapySDR whether the factory is still registered, which is the same check the installation used and confirms the removal from the runtime's point of view rather than the filesystem's. If a copy of the module survives somewhere else on the search path, this is where it shows up.

Using the Module from an Application

Once the module is installed, an application opens the card the same way it would open any other SoapySDR device. The only Amarisoft specific element is the 'driver=amarisoft' argument, and optionally a 'device' argument naming which board to use. If 'device' is omitted, the first '/dev/sdrN' node found is used.

SoapySDR::Kwargs args;

args["driver"] = "amarisoft";

args["device"] = "/dev/sdr2";

auto *dev = SoapySDR::Device::make(args);

 

dev->setSampleRate(SOAPY_SDR_RX, 0, 23.04e6);

dev->setFrequency  (SOAPY_SDR_RX, 0, 2140e6);

dev->setGain       (SOAPY_SDR_RX, 0, 40);

 

auto *rx = dev->setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, {0});

dev->activateStream(rx);

 

std::vector<std::complex<float>> buf(dev->getStreamMTU(rx));

void *buffs[] = { buf.data() };

int flags; long long timeNs;

int n = dev->readStream(rx, buffs, buf.size(), flags, timeNs, 1000000);

Because SoapySDR device arguments are themselves comma separated key and value pairs, the 'libsdr' multi-board syntax composes naturally. Passing 'dev0' and 'dev1' opens two boards as a single synchronised device, exactly as the underlying library expects.

driver=amarisoft,dev0=/dev/sdr0,dev1=/dev/sdr1

The device arguments below are recognised at construction time. Most of them mirror a normal setter and exist so that a device can be fully configured from a single argument string, which is convenient for applications such as GQRX that only offer one device string field.

device / path / node   single board to open (default: first found)

dev0, dev1, ...        multi-board form passed to msdr_open

rx_channels / tx_channels   channels advertised (default 2 / 2)

probe_info             allow a brief start to read board identity

sample_rate, frequency, rx_freq, tx_freq, rx_gain, tx_gain,

bandwidth, rx_antenna, clock_source, time_source, log_level

At run time, the settings interface exposes the knobs specific to this hardware. They are listed by 'SoapySDRUtil --probe' and can be changed with 'writeSetting'. The two most useful are 'auto_restart', which controls whether a configuration change while streaming triggers an immediate restart, and 'tx_max_lead_us', which controls the transmit throttle described earlier.

Supported Capabilities

Driver key         amarisoft

Hardware key       AD9361

Channels           2 RX, 2 TX per board, full duplex

Stream formats     CF32 (native, full scale 1.0), CS16

Frequency range    300 MHz .. 6 GHz accepted  (specified: 500 / 400 MHz up)

Sample rate        1 MHz .. 122.88 MHz continuous;

                  ladder n x 1.92 MHz, n = 1,2,3,6,9,12,16,18,24,32

RX gain            3 .. 71 dB below 4 GHz, 0 .. 62 dB above; AGC supported

TX gain            0 .. 89.75 dB

Antennas           RX: RX, TX_RX   TX: TX

Clock sources      internal, external, external_10mhz

Time sources       none, internal, gps, external, ptm, external_50

Sensors            fpga_temp, rx_overflow_count, tx_underflow_count,

                  abs_rx_power, abs_tx_power, gps_locked, info

The gain and bandwidth figures above are read from the board at run time and agree exactly with the values published in trx_sdr.doc: 0 to 89.75 dB of transmit gain, 3 to 71 dB of receive gain below 4 GHz narrowing to 0 to 62 dB above it, and an analogue bandwidth from under 200 kHz to 56 MHz. The frequency range is the one exception, and is discussed above: the figure quoted here is what the driver accepts, which is wider than the coverage specified in trx_sdr.doc and trx_sdr100.doc.

Limitations

The following limits are inherent to the current implementation or to the underlying library. They are listed here so that they are discovered before rather than during an experiment.

Directory Structure

The following summarises where everything ends up after a successful build and install, and which files are mandatory at run time.

## 1. Project source tree

 

/root/soapy_project/SoapyAmarisoft/

|  +-- CMakeLists.txt                [MANDATORY] build definition

|  +-- SoapyAmarisoft.hpp            [MANDATORY] class + HW constants

|  +-- Registration.cpp              [MANDATORY] factory registration

|  +-- Settings.cpp                  [MANDATORY] control plane

|  +-- Streaming.cpp                 [MANDATORY] data plane

|  +-- build.sh, run_tests.sh       helper scripts

|  +-- README.md                     design + measured HW behaviour

|  +-- tests/soapy_amarisoft_test.cpp   test tool source

|  +-- build/                        created by cmake

|      +-- libamarisoftSupport.so     the module (before install)

|      +-- tests/soapy_amarisoft_test  test tool binary

 

## 2. Installed module

 

/usr/local/lib64/SoapySDR/modules0.7/

   +-- libamarisoftSupport.so       [MANDATORY] scanned by SoapySDR

                                         at library load time

 

## 3. SoapySDR files (from SoapySDR / SoapySDR-devel)

 

/usr/include/SoapySDR/             [MANDATORY] headers to build against

/usr/lib64/libSoapySDR.so          [MANDATORY] runtime library

/usr/share/cmake/SoapySDR/         [MANDATORY] SOAPY_SDR_MODULE_UTIL macro

 

## 4. Amarisoft SDK files required to build and run

 

/root/trx_sdr/                     symlink to the trx_sdr release

|  +-- api/libsdr.h                [MANDATORY] msdr_* API declarations

|  +-- api/libsdr.so               [MANDATORY] linked, and found via RPATH

|  +-- kernel/flags.h              [MANDATORY] included by libsdr.h

 

/dev/sdr0 .. /dev/sdr3             [MANDATORY] created by the sdr kernel

                                         module (lsmod | grep sdr)

                                         a board already opened by lteue or

                                         lteenb cannot be opened by SoapySDR