M0LTE.Flex
0.14.1
dotnet add package M0LTE.Flex --version 0.14.1
NuGet\Install-Package M0LTE.Flex -Version 0.14.1
<PackageReference Include="M0LTE.Flex" Version="0.14.1" />
<PackageVersion Include="M0LTE.Flex" Version="0.14.1" />
<PackageReference Include="M0LTE.Flex" />
paket add M0LTE.Flex --version 0.14.1
#r "nuget: M0LTE.Flex, 0.14.1"
#:package M0LTE.Flex@0.14.1
#addin nuget:?package=M0LTE.Flex&version=0.14.1
#tool nuget:?package=M0LTE.Flex&version=0.14.1
M0LTE.Flex
A dependency-free .NET client for FlexRadio 6000-series SDRs over the SmartSDR TCP/UDP API. It gives you the radio as three simple seams — audio in, audio out, and PTT — so a modem, a paging encoder, or any DSP can drive a Flex the way it would drive a sound card, plus the discovery, command/status session and VITA-49 DAX plumbing underneath.
┌──────────────┐ discover / connect ┌───────────────────────────┐
│ your app │ ────────────────────────▶│ FlexClient (TCP :4992) │
│ (modem, DSP, │ IAudioInput (RX) ◀────│ FlexStation (slice+DAX) │──▶ FLEX-6000
│ paging, …) │ IAudioOutput (TX) ────▶│ VITA-49 DAX (UDP :4991) │
│ │ IPttControl ─────────▶│ slice PTT (xmit 1/0) │
└──────────────┘ └───────────────────────────┘
- Targets
net10.0. One dependency:M0LTE.Radio.Audio— the sharedIAudioInput/IAudioOutput/IPttControlseam. - Two bring-up modes: headless (this client creates its own slice — no SmartSDR running) and attach (bind a slice a running SmartSDR already owns).
- Both DAX transports: reduced-bandwidth 24 kHz s16 and full-bandwidth 48 kHz float32.
- Hardware-free tests: an in-process
MockFlexRadiospeaks enough of the protocol to loop transmit audio back as receive audio.
Install
dotnet add package M0LTE.Flex
Quick start
using M0LTE.Flex;
// 1. Find a radio on the LAN and open the session (or FlexClient.ConnectAsync("192.168.1.50")).
await using FlexClient client = await FlexClient.DiscoverAndConnectAsync(
spec: null, timeout: TimeSpan.FromSeconds(10));
// 2. Pick a DAX transport for your sample rate, then bring up a slice + DAX streams.
// SetUpHeadlessAsync creates our own slice; SetUpAsync attaches to a running SmartSDR's.
DaxStreamFormat format = DaxStreamFormat.FullBandwidth; // 48 kHz float32 (or .ReducedBandwidth for 24 kHz s16)
var options = new FlexStationOptions
{
SliceLetter = "A",
Frequency = "14.100000",
SliceMode = "DIGU",
DaxChannel = "1",
};
await using FlexStation station = await FlexStation.SetUpHeadlessAsync(client, format, options);
// 3. Receive, transmit and key through the seams (M0LTE.Radio.Audio).
using M0LTE.Radio.Audio;
IAudioInput rx = station.CreateAudioInput();
IAudioOutput tx = station.CreateAudioOutput();
IPttControl ptt = station.CreatePtt();
Span<float> buffer = new float[1024];
int got = rx.Read(buffer); // normalised floats (−1..1) at format.SampleRate
ptt.Key();
tx.Write(mySamples); // your modulated audio at format.SampleRate
tx.Drain(); // block until the audio has left the radio…
ptt.Unkey(); // …then release
DaxStreamFormat.ForDspRate(rate) picks the transport that bridges your DSP rate with an
integer ratio (48000 → full-bandwidth 1:1; 12000/24000 → reduced-bandwidth). The audio seams
always present samples at format.SampleRate; resample to your own rate on the way in/out.
Wideband IQ receive (DAX-IQ)
Stream raw complex baseband from one slice — before the SSB filter and AGC — for a wideband decoder or to fan several channels out of a single capture.
using M0LTE.Flex;
await using FlexClient client = await FlexClient.ConnectAsync("192.168.1.50");
// A 96 kSPS DAX-IQ stream centred on 14.100 MHz (rates: 24/48/96/192 kSPS).
await using FlexDaxIqSource iq = await FlexDaxIqSource.OpenAsync(
client,
new FlexDaxIqOptions(FrequencyMHz: "14.100000", Antenna: "ANT1", DaxChannel: 2, RateKsps: 96),
ownsClient: true);
// Read blocks of interleaved I, Q floats at iq.SampleRate (host-endian). Read blocks until data
// arrives, and returns 0 once disposed — pump it like a capture device.
Span<float> block = new float[8192];
int got = iq.Read(block); // got floats = got / 2 complex samples
Console.WriteLine($"{iq.PacketsReceived} packets, {iq.PacketsLost} lost");
FlexDaxIqSource implements IIqSource (SampleRate / CentreFrequencyHz / Read), so a
digital-downconverter front end can fan it into several narrowband channels.
IQ transmit (Waveform API)
Transmit arbitrary complex IQ through a custom waveform — the only way to put IQ on air on a 6000-series radio.
Say where the signal goes and how wide it is, and the library places it:
using M0LTE.Flex;
await using FlexClient client = await FlexClient.ConnectAsync("192.168.1.50");
await using FlexWaveform waveform = await FlexWaveform.SetUpHeadlessAsync(client, new FlexWaveformOptions
{
// Where the signal goes, how wide it is, and which convention you write in.
Band = new IqBand(14.200000, 3000, IqBandReference.LowerEdge),
RfPower = 5,
});
using FlexWaveformIqOutput iq = waveform.CreateIqOutput();
FlexPtt ptt = waveform.CreatePtt(confirmInterlock: true);
ptt.Key(); // the radio starts pulling TX buffers from us
iq.Write(myComplexIq); // your baseband, 0 … +3000 Hz, at iq.SampleRate (24 kHz)
iq.Drain(TimeSpan.FromSeconds(5));
ptt.Unkey();
// On air: 14.200000 – 14.203000 MHz, spectrum upright.
IqBand's reference names the convention you write in, and is the only thing you have to decide:
IqBandReference |
You supply | FrequencyMhz means |
|---|---|---|
Centre (default) |
DC-centred baseband, −bw/2 … +bw/2 — the usual SDR convention (GNU Radio, SoapySDR, UHD, SigMF) |
the centre of the band |
LowerEdge |
one-sided baseband, 0 … +bw |
the lower edge of the band |
The library then works out everything the radio makes awkward, and reports what it did on
SliceFrequencyMhz, BasebandShiftHz, OccupiedBand and TransmitFilter.
Why that is worth having
Three properties of the transmit path all report err=0 and are invisible without an external
receiver (measured on a FLEX-6500, firmware 4.1.5, 2026-07-26):
- It is single-sideband, and only the negative half survives. Every
underlying_modetransmits only the negative half of your baseband — positive-frequency content never reaches the air. What differs is which side of the carrier it lands on, and hence whether your spectrum arrives upright:RAW/LSB/DIGLplace−fatslice − f(upright), whileIQ/USB/DIGUplace it atslice + f(mirrored).AM/FMdiscard Q entirely. A band left straddling the carrier loses half its width. - The transmit filter caps the rest. It defaults to a 3 kHz SSB passband and clamps at 10 kHz, so
the usable width is about 10 kHz one-sided. It is set with
filter_low=/filter_high=but reported aslo/hi—transmit set hi=is rejected. It is also a global radio setting that persists and affects ordinary SSB (factory value 0–3000 Hz). - The waveform's own
tx_filterdoes nothing.TxFilterLowHz/TxFilterHighHzare accepted witherr=0and have no measurable effect on this firmware. - The rate is fixed at 24 kHz complex and cannot be raised. The firmware's own usage string for
waveform setenumerates every parameter it takes —rx_filter|tx_filter|tx|logging|udpport— and no rate is among them;slice set <n> sample_rate=is accepted witherr=0and ignored, the slice still reportingsample_rate=24000. The measured cadence agrees: 128 complex samples pulled 187.5 times a second, exactly 24 kSPS. It costs nothing, because the ~10 kHz transmit filter is the real limit and sits well inside the ±12 kHz that 24 kHz already gives. Receive is a different path — DAX-IQ runs at 24/48/96/192 kSPS andFlexDaxIqSourcesupports all four.
Band placement absorbs all three: it derives the slice frequency, frequency-shifts your samples into the sideband that actually transmits, opens the transmit filter far enough, and fails setup rather than putting a truncated signal on air if the radio cannot honour the width.
The shift is a true frequency translation, never a spectral mirror. Conjugating would land a one-sided baseband on the other sideband just as neatly, and invert it — so a QPSK or OFDM signal would look perfect on a spectrum analyser and decode nowhere.
Placing the IQ yourself
Set SliceFrequencyMhz instead of Band and the slice tunes there with your samples going out
untouched — for replaying a capture verbatim, or moving a signal around within the band without the
dial shifting under you. You then own the sideband and filter problems above.
The two are mutually exclusive and exactly one must be set: which mode you are in is visible at the call site rather than implied by whether some other property happens to be filled in, and there is no default transmit frequency to be surprised by.
The waveform is reflection-driven either way: while keyed, the radio streams TX buffers and
FlexWaveformIqOutput reflects your buffered IQ back for each one.
DAX audio transmit
FlexStation + FlexAudioOutput is the sound-card path: real mono audio into an ordinary slice,
landing above the dial in DIGU/USB and below it in DIGL/LSB.
Measured on a FLEX-6500 (fw 4.1.5, 2026-07-26), two things that are easy to get wrong:
- The transmitter must be pointed at DAX (
transmit set dax=1). Creating the DAX streams and pushing packets into them is not enough — the transmitter has its own audio-source selection which defaults to the mic, and every command in the DAX enable returnserr=0either way. A 1 kHz tone produced no modulation at all until this was sent.SetUpHeadlessAsyncnow does it and reads it back (TransmitSourceIsDax); setSelectDaxAsTransmitSource = falseto decline. - Bandwidth is the transmit filter, not the slice. An audio sweep was cut at exactly 10 kHz
with the filter at 10000, and at exactly 3 kHz with it at 3000. DAX is not a ~3 kHz path — it
carries whatever that filter allows, up to the same 10 kHz ceiling the waveform path has. It is a
global setting, so the default is to leave it alone and report it on
TransmitFilter; setTransmitFilterHighHzto change it.
Both settings persist after teardown and affect what the radio transmits from thereafter.
- Receive has a filter of its own, on the slice. The transmit filter governs what leaves the
radio; what reaches DAX-RX is capped separately by the slice's own passband, so widening only the
transmit side gives a wide signal out and an ordinary ~3 kHz window back in.
ReceiveFilterLowHz/ReceiveFilterHighHzset it (filt <n> <lo> <hi>), headless only - in attach mode the slice is SmartSDR's. Unlike the transmit filter this is slice state, so it goes away with the slice rather than persisting on the radio. Note the same report-one-way/write-another asymmetry the transmit filter has: a slice reports its passband asfilter_lo/filter_hi, but onlyfiltmoves it. Writing the reported names back withslice setdoes not move the filter, which is what 0.11.0 through 0.13.0 did, so the receive filter never took on hardware (observed at GB7RDG on 2026-08-14: asked for 450-2550 Hz, the slice stayed on 0-3000 for the whole setup timeout. Whether the radio refuses that write or accepts and ignores it is not measured, because the version that sent it swallowed the error code). And the read-back has to ask.filtmoves the filter and says nothing further: measured on the same 6500, afiltthat visibly narrowed the DSP produced no slice status on the session that sent it, while a second client saw the new edges immediately. A repeatedsub slice allre-dumps every slice in ~40 ms, and the dump lands before that command's own reply, so the confirmation is a synchronisation point rather than a poll. 0.14.0 sent the right command and then read its own stale copy, reporting a filter that had in fact moved; 0.14.1 asks. The radio's ceiling on receive width is not measured, unlike the transmit filter's 10 kHz clamp, so nothing assumes one: the filter is read back onReceiveFilter, andReceiveFilterWarningsays which edge missed, in which direction, and whether the radio refused the command or took it and did nothing.MockFlexRadio.MaxSliceFilterHighHzmodels a radio that will not go that wide;MockFlexRadio.DiscardSliceFilterWritesone that accepts and ignores.
Testing without a radio
using M0LTE.Flex;
await using var mock = new MockFlexRadio(DaxStreamFormat.FullBandwidth, MockRxMode.Loopback, MockSetupMode.Headless);
mock.Start();
await using FlexClient client = await FlexClient.ConnectAsync("127.0.0.1", mock.TcpPort, mock.UdpPort);
mock.RxDelivery = client.DeliverVitaPacket; // deliver the mock's DAX in-process (lossless)
client.VitaSendHook = mock.DeliverTxPacket; // capture what we transmit
See the test project for full loopback examples exercising the reorder ring, the headless and attach bring-up sequences, and the VITA-49 codec.
Stability & versioning
The public API is locked by a build-time test (PublicApiTests compares the surface to a
committed snapshot), and the package follows Semantic Versioning. Any
change to the public surface shows up in the diff and must be paired with the right version
bump — see docs/versioning.md.
Licence & provenance
AGPL-3.0-or-later (see LICENSE). Parts of the wire implementation are ports of
the MIT-licensed Go reference clients by Andrew Rodland (KC2G) and Frank Werner-Häcker
(HB9FXQ); the attributions are in PROVENANCE.md. Not affiliated with or
endorsed by FlexRadio Systems.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- M0LTE.Radio.Audio (>= 0.1.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on M0LTE.Flex:
| Package | Downloads |
|---|---|
|
pdn-soundmodem
Headless soundcard packet modem core: IL2P/AX.25 framing and FEC now; demodulators, KISS TCP and DCD to follow. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.14.1 | 0 | 8/14/2026 |
| 0.14.0 | 0 | 8/14/2026 |
| 0.13.2 | 34 | 8/14/2026 |
| 0.13.1 | 44 | 8/13/2026 |
| 0.13.0 | 53 | 8/13/2026 |
| 0.12.0 | 159 | 8/6/2026 |
| 0.11.0 | 237 | 8/3/2026 |
| 0.10.0 | 223 | 8/2/2026 |
| 0.9.2 | 180 | 8/2/2026 |
| 0.9.1 | 106 | 8/2/2026 |
| 0.9.0 | 104 | 8/2/2026 |
| 0.8.2 | 165 | 7/27/2026 |
| 0.8.1 | 96 | 7/27/2026 |
| 0.8.0 | 103 | 7/26/2026 |
| 0.7.0 | 96 | 7/26/2026 |
| 0.5.0 | 99 | 7/25/2026 |
| 0.4.0 | 99 | 7/25/2026 |
| 0.3.0 | 125 | 7/18/2026 |
| 0.2.0 | 100 | 7/17/2026 |
| 0.1.0 | 98 | 7/17/2026 |