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
- SoapySDR
- Introduction
- Summary of the Tutorial
- If you are a human
- What is the final goal ?
- SoapySDR Install
- Step 1 - Install SoapySDR and build tools
- Step 2 - Check the baseline installation
- Step 3 - Locate the development headers
- Step 4 - Understand the interface to implement
- Building the Amarisoft Support Module
- Amarisoft libsdr Behaviour the Module Handles
- An out of range frequency terminates the process
- There is no live retune
- msdr_read works in whole DMA hyperframes
- msdr_write does not block
- Calls that are only valid once the device is started
- Verification
- hellosoapy - a Self Contained Test Program
- detectssb - Demonstrating Real Time Capability
- Using the Module from Python
- Installing/Uninstalling soapy_project package
- Using the Module from an Application
- Supported Capabilities
- Limitations
- Directory Structure
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.
-
Context and Background
- Amarisoft SDR cards are used for advanced wireless protocol prototyping, research, and experimentation in cellular networks and other wireless domains.
- SoapySDR abstracts diverse SDR hardware under a single API, promoting interoperability and rapid development of cross-platform radio applications.
- Without a native SoapySDR support module, Amarisoft hardware cannot be used directly by SoapySDR applications, limiting flexibility and software integration options.
-
Relevance and Importance
- Enables the use of Amarisoft SDR cards with the full suite of SoapySDR-compatible applications, including real-time signal processing, spectrum analysis, and wireless protocol development tools.
- Facilitates cross-platform SDR workflows, reducing vendor lock-in and expanding the usability of Amarisoft hardware beyond proprietary software.
- Empowers both researchers and developers to leverage powerful SDR capabilities using standardized, open-source interfaces.
-
Learning Outcomes
- Understand the architectural principles behind SDR abstraction using SoapySDR and how this applies to Amarisoft hardware.
- Gain hands-on experience building and installing a custom SoapySDR support module for Amarisoft SDR cards.
- Learn how to configure and validate the integration, enabling use of Amarisoft hardware with popular SDR applications.
- Acquire troubleshooting skills for common integration and configuration issues.
-
Prerequisite Knowledge and Skills
- Familiarity with Linux system administration and command-line tools.
- Basic understanding of SDR concepts and wireless communication principles.
- Experience with building software from source and installing development packages.
- Rudimentary knowledge of C or C++ programming and shared library usage is helpful but not strictly required.
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.
- Goal and Architecture:
- The target is a card that any SoapySDR client can enumerate, open, probe and stream from, with gain, antenna, frequency, sample rate, timestamps, sensors and settings all reachable through the standard calls.
- Four layers are involved: the SDR hardware, the Amarisoft sdr.ko driver with its libsdr.so user space library, the support module libamarisoftSupport.so built here, and the SoapySDR runtime with the application above it.
- The module is the only component that knows both APIs. The application depends on SoapySDR alone, so neither side has to change when the other does.
- Success is defined concretely: the stock SoapySDRUtil tool probes the card and prints a full capability report, and a streaming client reads continuously with no overflows and no gaps in the sample timestamps.
- Installing SoapySDR:
- Both the runtime and the SoapySDR-devel headers are needed, since the module is compiled against them. The tutorial uses SoapySDR 0.7.1 on an Amarisoft UEsim running Fedora 32.
- The build must happen on a Callbox or UEsim, because it links the Amarisoft SDK and requires the SDR hardware to be present.
- A baseline run of SoapySDRUtil --info should report no modules and no factories, which is the correct starting state before any code is written.
- The ABI version matters: a module built against one SoapySDR ABI will not be loaded by a runtime with a different one.
- Building the Support Module:
- The implementation is split by responsibility. Registration.cpp holds the factory and registry, Settings.cpp the control plane, Streaming.cpp the data plane, and SoapyAmarisoft.hpp the class declaration and the measured hardware constants.
- SoapyAmarisoft.hpp is the only file that includes libsdr.h, so there is exactly one place to change if the SDK moves.
- The SOAPY_SDR_MODULE_UTIL CMake macro handles the module name, output directory and install destination, so the result lands in the SoapySDR search path automatically.
- Three build details are easy to miss: both the api and kernel include directories are required, dl, pthread and m must be linked explicitly, and the location of libsdr.so has to be recorded as an RPATH or the module builds but silently fails to load.
- Enumeration deliberately does not open the boards, because access is exclusive and probing would hide any device already held by lteue, lteenb or another client.
- Amarisoft libsdr Behaviour the Module Handles:
- An out of range frequency makes libsdr call exit() rather than return an error, which would take the whole application with it. The module range checks every tune request first and raises an ordinary exception instead.
- There is no live retune, since msdr_set_freq_fast returns -1 on the AD9361 front end. Any change of frequency, rate, bandwidth, antenna or clocking becomes a stop, reconfigure and restart cycle, measured at roughly 130 ms at 30.72 Msps and up to 1.5 s at 1.92 Msps.
- msdr_read works only in whole DMA hyperframes, which is what sets the stream MTU and why the read path stages any remainder the caller had no room for.
- msdr_write does not block, so the module throttles transmit itself against the hardware sample counter. That counter is expensive to read, so it is sampled every 100 ms and interpolated in between.
- Several calls segfault before msdr_start, including the gain limit queries and msdr_dump_info. The module brings the board up briefly on first use to collect them, which can be turned off with the probe_info device argument.
- Verification:
- The checks move from cheapest to most demanding. SoapySDRUtil --info confirms the module loaded and the factory registered, --find exercises enumeration, and --probe opens a board and prints its full capability report.
- A test tool and a shell script then run every mode and print a pass or fail tally, picking a free device automatically. The transmit modes are skipped unless --tx is given, since they key the transmitter.
- A healthy receive run shows the effective rate matching the configured rate, with no overflows, no discontinuities, and a timestamp span that agrees with the sample count.
- Reference results on an AD9361 board include 23.042 and 30.722 Msps with zero overflows, retunes at 170 ms with streaming resuming each time, transmit paced with no underflows, and out of range frequencies rejected by exception with the process surviving.
- hellosoapy, a Self Contained Test Program:
- One program that can be dropped onto a machine, compiled and run, performing eleven checks in increasing order of demand and returning an exit status suitable for a smoke test or a CI job.
- It contains no Amarisoft header and no libsdr symbol, and links only against libSoapySDR.so. That is what makes it a fair test rather than a private back channel to the implementation.
- detectssb, Demonstrating Real Time Capability:
- Correctness alone does not answer whether the abstraction is fast enough, so this program runs a live 5G NR SSB detector on the sample stream with no recording and no offline pass.
- It correlates against the three PSS sequences to recover N_ID_2 and the symbol timing, decodes the SSS for N_ID_1, and reports the physical cell identity with the hardware timestamp of each SSB.
- The task is self checking: the cell identity either matches the transmitting cell or it does not, and the fixed SSB period makes any missed detection visible in the interval between timestamps.
- Give it the SSB ARFCN rather than the cell centre. In an Amarisoft configuration ssb_nr_arfcn is the field to use, not dl_nr_arfcn.
- Using the Module from Python:
- Nothing is rebuilt or reconfigured for Python. The bindings sit on the same runtime, so a Python application reaches the card through exactly the same code path a C++ one does.
- A minimal receiver shows the mandatory sequence, along with two details that are easy to get wrong: a timeout return is an ordinary event rather than an error, and the return value is the number of samples actually delivered rather than the number requested.
- A numpy port of the SSB detector reaches 5.736 Msps against a configured 7.680 and accumulates overflows, so some SSBs are never seen. The streaming interface is not the bottleneck in either language.
- The practical conclusion is that Python suits control and block at a time analysis, while sustained sample rate processing belongs in a compiled language. Both reach the same hardware, so the two can be mixed freely.
- Packaging, Installation and Limits:
- Everything lives in a single soapy_project tree that is shipped as source and compiled in place, because the module records an RPATH at build time and is loaded only by a runtime with a matching ABI.
- The scripts offer non-destructive forms, name the thing to fix in every failure message, and verify the result independently of the step that produced it, which is what lets a coding agent carry out the procedure unattended.
- Gain and bandwidth figures are read from the board at run time and agree with the published values. The frequency range is the exception, since the guard sits at the wider measured limit to keep the process alive rather than to enforce the datasheet.
- Known limits include one stream per direction, a single RF port, no timed or burst activation, no setHardwareTime, no register or GPIO style interfaces, constants measured on one AD9361 board, and timestamps that restart on every retune.
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.
|
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/ Available factories... |
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
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! |
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) |
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.
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); |
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 |
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.
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 '/dev/sdr[0-9]*' | 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 = 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= hardware= 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 |
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
|
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 |
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/ |
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
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.
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();
}
|
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 |
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.
- Every SSB was caught. An NR cell repeats its SSB every 20 ms, so half a second contains exactly twenty five of them, and twenty five is what was reported. The timestamps confirm it directly: each is 20,000,000 ns after the one before, with no gaps.
- The sample rate was sustained. The summary reports the same throughput as the configured rate. Had the correlator fallen behind, the figure would have dropped below it.
- The hardware never discarded anything. The receive overflow counter is zero. This is the strongest of the three, because it is kept by the FPGA rather than by the program: it counts the times the card had samples ready that nobody collected in time.
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
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.
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;
|
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': '
|
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
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.
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:
|
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.
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:
|
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) |
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.
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.
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 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.
- One stream per direction. 'libsdr' keeps a single read and write cursor per RF port, so two concurrent readers would steal each other's samples. 'setupStream' rejects the second one with a clear error rather than allowing silent corruption.
- A single RF port ('rf_port 0') is configured. Multi-port CPRI setups are outside the scope of this module.
- Timed and burst limited 'activateStream' are not supported. Receive starts immediately and runs until 'deactivateStream'. A warning is logged if a timestamp or burst count is supplied.
- 'setHardwareTime' is not implemented, because the sample counter is owned by 'libsdr' and cannot be set from the API.
- The register, GPIO, I2C, SPI and UART interfaces are not implemented, since 'libsdr' exposes no equivalent.
- The frequency and gain limits are AD9361 values measured on one board. Other Amarisoft front ends, such as AD937x or a CPRI connected RRH, will need the constants in 'SoapyAmarisoft.hpp' revisited.
- Timestamps restart whenever the hardware is restarted, which includes every retune. They are monotonic only within a single activation.
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 |