M0LTE.Flex 0.14.1

dotnet add package M0LTE.Flex --version 0.14.1
                    
NuGet\Install-Package M0LTE.Flex -Version 0.14.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="M0LTE.Flex" Version="0.14.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="M0LTE.Flex" Version="0.14.1" />
                    
Directory.Packages.props
<PackageReference Include="M0LTE.Flex" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add M0LTE.Flex --version 0.14.1
                    
#r "nuget: M0LTE.Flex, 0.14.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package M0LTE.Flex@0.14.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=M0LTE.Flex&version=0.14.1
                    
Install as a Cake Addin
#tool nuget:?package=M0LTE.Flex&version=0.14.1
                    
Install as a Cake Tool

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 shared IAudioInput/IAudioOutput/IPttControl seam.
  • 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 MockFlexRadio speaks 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_mode transmits 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/DIGL place −f at slice − f (upright), while IQ/USB/DIGU place it at slice + f (mirrored). AM/FM discard 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 as lo/hitransmit 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_filter does nothing. TxFilterLowHz/TxFilterHighHz are accepted with err=0 and 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 set enumerates every parameter it takes — rx_filter|tx_filter|tx|logging|udpport — and no rate is among them; slice set <n> sample_rate= is accepted with err=0 and ignored, the slice still reporting sample_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 and FlexDaxIqSource supports 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 returns err=0 either way. A 1 kHz tone produced no modulation at all until this was sent. SetUpHeadlessAsync now does it and reads it back (TransmitSourceIsDax); set SelectDaxAsTransmitSource = false to 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; set TransmitFilterHighHz to 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/ReceiveFilterHighHz set 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 as filter_lo/filter_hi, but only filt moves it. Writing the reported names back with slice set does 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. filt moves the filter and says nothing further: measured on the same 6500, a filt that visibly narrowed the DSP produced no slice status on the session that sent it, while a second client saw the new edges immediately. A repeated sub slice all re-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 on ReceiveFilter, and ReceiveFilterWarning says which edge missed, in which direction, and whether the radio refused the command or took it and did nothing. MockFlexRadio.MaxSliceFilterHighHz models a radio that will not go that wide; MockFlexRadio.DiscardSliceFilterWrites one 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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