The Case of the Missing Transmit Power

How a 4-bit Misalignment Stole 24 dB from the Opulent Voice Modem

The Opulent Voice modem for the LibreSDR graduated from the lab to the field in late March 2026. Instead of coaxial cables connecting transmitter to receiver, and receiver to transmitter, we now connected our brave little radios to filters and outdoor antennas. 

And, nothing was received. The signal levels appeared to be very low. Even moving the antennas right next to each other resulted in only a few scattered frames demodulated and decoded. 

Obviously, we needed an amplifier. Fortunately, we had plenty in stock from collaborating with University of Puerto Rico’s RockSatX team. They used an earlier version of Opulent Voice on their sounding rocket. 

From the original listing at https://www.ebay.com/itm/363233702995

—————————————————
Microwave RF Power Amplifier Board SBB5089+SHF0589 40MHz-1.2GHz Gain 25DB 10PCS

Specifications:

– Input voltage: 10~30V DC
– Input power: about 5W
– Working frequency: 40MHz~1.2GHz (0.04~1.2GHz)
– Gain: about 25dB (may be higher)
– Power: 2W (may be higher)

Attention:

We measured 80.7% ultra-high efficiency in tests, and the official chip manual also mentioned that there is more than 50% efficiency at P1dB. Overall, this SBB5089+SHF0589 is better than SBB5089+SHF0289.
—————————————————

On 24 March 2026, we selected one of the amplifiers at random in order to characterize it in ORI’s Remote Labs. We connected the input of the amplifier to the output of the DSG821A signal generator. The signal generator was set to 431 MHz, which was the frequency we wanted to use. We connected the output of the amplifier through a 6 dB attenuator to the Rigol RSA5065N Spectrum Analyzer. We fitted a JST-HX power cable to the power connector of the amplifier. We provided 12 volts of power from the DP832 lab power supply.

The amplifier made 27 dB from -100 dBm input to about -3 dBm input, made 20 dB at 0 dBm input, and worked pretty well up to 9 dBm input. 

The next test was to remove the signal generator and connect a LibreSDR running Opulent Voice. Instead of a carrier wave from the signal generator we’d be sending an 81 kHz wide minimum shift key (MSK) signal from a real modem through the amplifier. We were intending to repeat the measurements we’d made with the signal generator. However, we noticed something very interesting. The signal level from the LibreSDR was expected to be about 0 dBm, which would provide enough drive to the amplifier to create enough gain to help our over-the-air tests succeed. However, when the LibreSDR, running Locutus and Dialogus, was commanded to transmit with PTT and audio frames from Interlocutor, the peak of the main lobe of the MSK signal was at -30 dBm. If this was the true power output of the LibreSDR, then no wonder the over-the-air tests had failed. 

The transmit power hardware gain setting was confirmed to be at 0 dBm. This is set through an Industrial Input and Output (IIO) library attribute call, was correctly reported, and we saw that changing the attribute caused the signal to increase or decrease by the exact amount of gain. So, it wasn’t a configuration error. As far as the hardware was concerned, it was transmitting at 0 dBm. 

The other possibility was that the I and Q signals were not being generated for transmit at full scale. If we weren’t filling up the registers correctly, then maybe we were accidentally dividing our signal down before it got to the antenna. Investigation turned to the Hardware Descriptive Language (HDL) files. 

The Opulent Voice VHDL language modem, called Locutus, runs inside the LibreSDR FPGA. Data frames arrive via direct memory access, pass through the Opulent Voice frame encoder, are convolutionally encoded (K=7, rate 1/2), go through a byte-to-bit deserializer, and the resulting bits are sent to the MSK modulator. The modulator produces the I and Q samples that drive the AD9363 digital to analog converter (DAC). Software in the general purpose processor of the LibreSDR configures the IIO context and controls PTT. 

The direct memory access transfers protocol data frames into the LibreSDR, and not IQ samples. So, the classic PlutoSDR bug of 12-bit samples being miscounted in a 16-bit word did not apply here. The modulator itself generates all I and Q waveforms. The frequencies are set by Dialogus at startup.

The Integrated Logic Analyzer (ILA) in the bitstream already had probes on two very important signals,  tx_i_sync and tx_q_sync. These signals were measured right at the point where the samples enter the AD9361 core. A January 2026 ILA capture told the story clearly. The waveform showed clean MSK signals. No corruption, no skips, and with the exact right relationship to each other. At the time, this was a big milestone and part of the process of troubleshooting the porting of the HDL code from the PlutoSDR to the LibreSDR. But we’d overlooked something critical. The bug was right there in an otherwise perfect image.

The peak values of the I and Q waveforms were only plus and minus 1100 or so, in a 16-bit signed word. At first glance, a value of 1100 in a 16-bit word might not raise any red flags. The alarm bells ring when you know how the axi_ad9361 core actually reads those 16 bits. 

There are two different conventions on the same bus. The axi_ad9361 core uses 16-bit data buses internally. However, the AD9361 and AD9363 (the chips used in these software-defined radios) have only 12-bit digital to analog converters. The documented convention, confirmed by a tour through Analog Devices Engineer Zone forum, is as follows. 

RX (ADC Output) is 12-bit value in [11:0], sign extended to [15:12]
TX (DAC Output) is 12-bit value expected in [15:4], which is the top 12 bits

In plain English, RX gives you the data right-justified. TX expects it left-justified. These are opposite conventions on the same 16-bit bus, and the apply whether the interface is CMOS (PlutoSDR) or LVDS (LibreSDR). 

Our code, from msk_modulator.vhd, in the carrier_mod_proc section looks like this. 

tx_samples_I <= std_logic_vector(resize(s1s + s2s, SAMPLE_W));
tx_samples_Q <= std_logic_vector(resize(s1c + s2c, SAMPLE_W));

s1s and s2s are each signed 12-bit values from the numerically controlled oscillator (NCO). The lookup table fills using the command

ROUND(SIN(theta) * 1024.0), which gives a peak value of plus or minus 1024. VHDL addition of two such values produces a 12-bit result that ranges from -2048 to +2048. So far so good. The resize call then sign-extends that 13-bit result into 16 bits. This is a right-justified 16-bit word, which is the opposite of what the Analog Devices core expects. 

The full chain of what happens to the signal amplitude can be calculated. 

The lookup table output is [11:0] signed and is a 12-bit sinusoid. 
s1s + s2s is [12:0] signed and is a 13-bit sum. 
Resize(…, 16) [15:13] sign extension with [12:0] as the data. This is right-justified.
Analog Devices chip reads transmit values as [15:4], sending the top 12 bits to the DAC.
Analog Devices reads [15:4], we drive [12:0], and this is a divide by 16 to the amplitude.

What’s the damage? -24 dB. 

Why did this work in the PlutoSDR? Well, it didn’t. It did not produce full power, either. The same modulator code drove the Pluto variant of Opulent Voice. The -24 dB bug was there too. Why did we not notice it? We never graduated to over-the-air tests with the PlutoSDR. All of the tests transmissions were in the lab and were either conducted through coaxial cables or done with Vivaldi lab antennas right next to each other on the bench. With conducted tests, everything worked perfectly. 

For ORI’s LibreSDR work, we were now in the field. We wanted to characterize the modem output before adding an amplifier. That scrutiny revealed the long-lived bug in the HDL. 

Matthew Wishek NB0X implemented a fix on the tx_sample_scale branch of the published repository, with changes to two submodules, the NCO and the msk_modulator. No changes to the block design TCL or to msk_top.vhd were required.

In the NCO (sin_cos_lut.vhd), a new constant was introduced: CONSTANT FULL_SCALE : INTEGER := 2**(SINUSOID_W-1) -1. And, the lookup table fill function was changed from the hardcoded 1024.0 to * real(FULL_SCALE). With SINUSOID_W = 12, this gives FULL_SCALE = 2047, willing the entire signed 12-bit range. The fix is fully generic. It works for any value of SINUSOID_W. 

-- Before:
tmp := ROUND(SIN(theta) * 1024.0);
-- After:
CONSTANT FULL_SCALE : INTEGER := 2**(SINUSOID_W-1) - 1;
tmp := ROUND(SIN(theta) * real(FULL_SCALE));

In the modulator (msk_modulator.vhd), a new 3-bit input port tx_shift : IN std_logic_vector(2 DOWNTO 0) was added. The IQ output assignment was changed from a plain resize() to a shift_left() whose amount is driven by tx_shift at runtime. 

-- Before:
tx_samples_I <= std_logic_vector(resize(s1s + s2s, SAMPLE_W));
-- After:
tx_samples_I <= std_logic_vector(
    shift_left(resize(s1s + s2s, SAMPLE_W), 
        to_integer(unsigned(tx_shift))));

The full 12-bit scale was achieved. With the sum now peaking at plus or minus 4094, left-shifting by 3 puts the signal in the correct place, which is [15:3]. The Analog Devices core reads [15:4], which is the full DAC scale. Making tx_shift a configurable port rather than a hardcoded constant is an elegant touch. Dialogus sets it through the register map at runtime, with no bitstream rebuild needed.

With the tx_sample_scale fix integrated and a new bitstream loaded, the Opulent Voice modem then achieved its first successful over the air transmission. This was from one building to another, with the full signal chain, from a LibreSDR to another LibreSDR. Voice traffic and text messages were received, with excellent audio quality. The ~30 dB shortfall that had been quietly sitting in the hardware since the original modulator was gone. 

Lessons Learned

RX and TX use opposite justify directions in axi_ad9361. This is documented, but really only in a so-called Verified Answer on Analog Devices Engineer Zone forum. It’s not prominently documented in the IP wiki. The wiki describes the 16-bit data base and mentions that the IP “always works in 16 bits”, but does not call out the left/right justification asymmetry in a way that is easy to find. If you are writing custom HDL that drives DACs, then you should read the forum thread at https://ez.analog.com/fpga/f/q-a/112155/axi_ad9361-data-format

ILA probes are worth their cost. The screenshot from the ILA capture back in January 2026 told us the answer, if we had known what the question was. Running ILA and keeping the results pays off because you can go back and look at signals that may not be accessible otherwise. Wire up ILA early and often and be curious about your signals. Go for a tour. Explore your design and the design of any infrastructure that you are working with. 

-24 dB is a recognizable signature. In fact, any multiple of -6 dB is significant. Each bit of DAC resolution is 6 dB, so if you’re missing something like 24 dB, then an inadvertent four-bit shift might be the culprit. 

Fix things at the right layer. The initial discussions included assumptions such as “the fix should live in the block design TCL file” or maybe in msk_top. Matthew chose to fix it inside the modulator and NCO submodules. This is the better choice. It makes the modules self-consistent, removes the need for platform-specific fancy workarounds or settings, and ensures that any future target automatically benefits. When a submodule’s output format is wrong, fix the submodule rather than papering over it at the integration layer. 

Acknowledgements

The modulator and NCO were written by Matthew Wishek NB0X, whose clean modular architecture made the bug straightforward to trace, and whose tx_sample_scale branch fix resolved it elegantly at the right layer. Thanks to the ADI FPGA team (Laszlo) for the EngineerZone Verified Answer that became our primary citation. Thanks to Paul KB5MU and Michelle W5NYV for working through this signal chain, characterizing the amplifier, and methodically testing the new firmware. 

ADI EngineerZone — AXI_AD9361 Data Format (Verified Answer): ez.analog.com/fpga/f/q-a/112155/axi_ad9361-data-format

ADI Wiki with AXI_AD9361 IP documentation: wiki.analog.com/resources/fpga/docs/axi_ad9361

ORI pluto_msk repository (tx_sample_scale branch): github.com/OpenResearchInstitute/pluto_msk

ORI msk_modulator repository: github.com/OpenResearchInstitute/msk_modulator

ORI nco repository (sin_cos_lut fix): github.com/OpenResearchInstitute/nco

Leave a Reply

Your email address will not be published. Required fields are marked *