<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: P.R.Decker</title>
    <description>The latest articles on DEV Community by P.R.Decker (@kg7hf).</description>
    <link>https://dev.to/kg7hf</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4144444%2F3a6221bb-4ebd-4f8f-89cd-0d06ddf83f5b.png</url>
      <title>DEV Community: P.R.Decker</title>
      <link>https://dev.to/kg7hf</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kg7hf"/>
    <language>en</language>
    <item>
      <title>Acquisition: finding the burst</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Fri, 16 Oct 2026 14:44:09 +0000</pubDate>
      <link>https://dev.to/kg7hf/acquisition-finding-the-burst-i1l</link>
      <guid>https://dev.to/kg7hf/acquisition-finding-the-burst-i1l</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of "Building a MIL-STD-188-110 HF Modem in Modern C++." Start with the &lt;a href="https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0"&gt;overview&lt;/a&gt;, Part 1, &lt;a href="https://dev.to/kg7hf/the-rules-i-build-by-1dj4"&gt;the rules I build by&lt;/a&gt;, Part 2, &lt;a href="https://dev.to/kg7hf/the-transmit-chain-the-easy-half-done-right-2540"&gt;the transmit chain&lt;/a&gt;, or Part 3, &lt;a href="https://dev.to/kg7hf/the-audio-front-end-finding-the-symbols-138l"&gt;the audio front end&lt;/a&gt;. The code for this article is on GitHub: &lt;a href="https://github.com/kg7hf/modem-core/tree/part-4" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 3 ended with symbols: the front end turns 48,000 audio samples a second into 2400 complex values a second, each read at a symbol's peak. It does not say what they are. A receiver listens to a channel that is mostly noise; somewhere in it a burst starts, in one of the standard's modes, with its carrier some hertz away from where the receiver expects it. Acquisition answers four questions from the symbols alone: is a burst there, where does it start, which mode is it, and where does its data begin. It also measures how far off the carrier is, so the stages after it start close.&lt;/p&gt;

&lt;p&gt;The standard makes that possible: every burst begins with a preamble whose symbols it fixes in advance. This article reads the preamble the way the standard defines it, then builds the search for it.&lt;/p&gt;

&lt;p&gt;Part 3 had &lt;code&gt;front_end_demo&lt;/code&gt;; this article has &lt;code&gt;acquisition_demo&lt;/code&gt;. Build it, run it, and keep its output open beside this article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build/acquisition_demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs fourteen steps in the order this article takes them, and every output block below comes out of one of them. Each step is a function in &lt;code&gt;tests/walkthrough/acquisition_steps.cpp&lt;/code&gt; that &lt;code&gt;main()&lt;/code&gt; calls by name, and each section names its step. In VS Code, press F5 and choose &lt;em&gt;acquisition_demo (gdb)&lt;/em&gt;; it stops at the top of &lt;code&gt;main()&lt;/code&gt;, so step into each step in turn, or set a breakpoint in the function for the section you are reading and continue to it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;preamble_segments()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The preamble, as the standard defines it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prefix_reference()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fading_prefix()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Correlating so a fade does not hide it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;correlation_comb()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A match at every segment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;carrier_range()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The carrier offset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;read_segment()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reading the mode and the countdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wake_mid_preamble()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waking up mid-preamble&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unsupported_mode()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A mode this modem does not implement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;segment_vote()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One segment, or all of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;noise_only()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No burst at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;code&gt;phase_search()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Part 3's hang-up, fixed while acquiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clock_offset()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The clock offset, or a second loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;code&gt;learning_time()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When to learn, and when to stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;code&gt;long_burst_rule()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When to learn, and when to stop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As in Part 3, the unit tests call the same functions: &lt;code&gt;tests/acquisition_tests.cpp&lt;/code&gt; runs each step and checks what it returned, so the walkthrough is under automated regression too.&lt;/p&gt;

&lt;p&gt;Steps 4, 6 and 7 work on one recording: the "Hi" burst from Parts 2 and 3 (600 bps, long interleave), half a second into a noisy line, with white noise 10 dB below the signal in a 3 kHz band and the whole signal moved 37 Hz off frequency. Step 9 uses the same burst with the noise turned up. Part 3's front end turns it into symbols, and acquisition gets nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preamble, as the standard defines it
&lt;/h2&gt;

&lt;p&gt;MIL-STD-188-110B describes the preamble in 5.3.2.3.7.2. It is three 200 ms segments for the zero and short interleave settings, and twenty-four for the long. Each segment is 15 three-bit channel symbols:&lt;/p&gt;

&lt;p&gt;0, 1, 3, 0, 1, 3, 1, 2, 0, D1, D2, C1, C2, C3, 0&lt;/p&gt;

&lt;p&gt;The first nine are fixed. D1 and D2 name the bit rate and the interleave setting (Table XV); 600 bps with the long interleave is D1 4, D2 6. C1, C2 and C3 are a count of the segments still to come, from 2 (zero and short) or 23 (long) down to 0 in the last segment. The count is a six-bit word, C1 holding the most significant two bits, and each two-bit piece is sent as a three-bit symbol with a 1 in front of it, so 23, binary 010111, goes out as 5, 5, 7 (Table XVI).&lt;/p&gt;

&lt;p&gt;Each channel symbol goes out as 32 transmitted symbols (Table XVII): one of eight patterns of tribits 0 and 4, phases 0 and 180 degrees, eight long and repeated four times. The sync randomizer (5.3.2.3.8.2) then adds its own 32-tribit sequence, mod 8, the way Part 2's data randomizer does in the body. Fifteen channel symbols of 32 make 480 symbols, exactly 200 ms at 2400 symbols a second.&lt;/p&gt;

&lt;p&gt;Step 1, &lt;code&gt;preamble_segments()&lt;/code&gt;, reads the transmitter's own preamble back: for each 32-symbol block it finds the channel symbol whose pattern, plus the randomizer, made those tribits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;segment   0 1 3 0 1 3 1 2 0 | D1 D2 | C1 C2 C3 | 0   countdown
      0   0 1 3 0 1 3 1 2 0 |  4  6 |  5  5  7 | 0          23
      1   0 1 3 0 1 3 1 2 0 |  4  6 |  5  5  6 | 0          22
  ...
     22   0 1 3 0 1 3 1 2 0 |  4  6 |  4  4  5 | 0           1
     23   0 1 3 0 1 3 1 2 0 |  4  6 |  4  4  4 | 0           0
24 segments; prefix on every one: yes; countdown runs down to 0: yes
D1 4, D2 6 on a long preamble: 600 bps, long interleave
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acquisition follows that table. The nine fixed symbols are the same in every segment of every burst, so the receiver can search for them without knowing anything else. Once it has found them, D1 and D2 in the same segment name the mode, and the countdown says how many segments remain before the data, so where the body starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reference
&lt;/h2&gt;

&lt;p&gt;The receiver searches for the first nine channel symbols, 288 transmitted symbols (I call them chips here, the usual name for the symbols of a spreading code), built exactly as the transmitter builds them. Step 2, &lt;code&gt;prefix_reference()&lt;/code&gt;, prints the first eight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chip  code  randomizer  tribit       I       Q
   0     0           7       7  +0.707  -0.707
   1     0           4       4  -1.000  -0.000
   2     0           3       3  -0.707  +0.707
   3     0           0       0  +1.000  +0.000
   4     0           5       5  -0.707  -0.707
   5     0           1       1  +0.707  +0.707
   6     0           5       5  -0.707  -0.707
   7     0           0       0  +1.000  +0.000
 ...  288 chips; the same as the first 288 symbols of every transmitted segment: yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Channel symbol 0's pattern is all zeros, so its 32 chips are the sync randomizer's own sequence. The randomizer's sequence is 32 long and a segment is 15 of its periods, so every segment starts it over: the 288 chips are the same at the start of every segment, and the step checks them bit for bit against all 24 segments the transmitter sent.&lt;/p&gt;

&lt;p&gt;The code is &lt;code&gt;make_body_prefix_reference()&lt;/code&gt;, in &lt;code&gt;modem/m110a/synchronization.cpp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;make_body_prefix_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix_symbols&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SyncRandomizer&lt;/span&gt; &lt;span class="n"&gt;randomizer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;channel_symbol&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;acquisition_prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;channel_symbol_chips&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;tribit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tribit_add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body_channel_symbol_tribit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel_symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;randomizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_tribit&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psk8_symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tribit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;body_channel_symbol_tribit&lt;/code&gt;, &lt;code&gt;SyncRandomizer&lt;/code&gt;, &lt;code&gt;tribit_add&lt;/code&gt; and &lt;code&gt;psk8_symbol&lt;/code&gt; are Part 2's transmitter functions, so the reference cannot disagree with what the transmitter sends.&lt;/p&gt;

&lt;p&gt;The reference never changes, so the scanner builds it once, on first use, into function-local static storage, the way &lt;code&gt;psk8_symbol&lt;/code&gt; keeps its constellation table. That was not the first version. The scanner used to build it on its own stack: 288 complex floats, over 2 KB, in a frame of nearly 5 KB. When I moved the code into this repository, its stack-usage check, which holds every function in the modem library to 4 KB, failed. In static storage the reference costs no stack, and no time after the first call, and the frame is back inside the limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlating so a fade does not hide it
&lt;/h2&gt;

&lt;p&gt;A correlation multiplies the received symbols by the conjugate of the reference and adds the products. At the start of a segment every product lines up, and the sum is large; anywhere else the products point every which way, and the sum stays small. Divided by the energies of both, the result is 1 for a perfect match and near 0 for none.&lt;/p&gt;

&lt;p&gt;One sum over all 288 chips assumes the channel holds still for the 120 ms they take. An HF channel does not. A fading path changes its gain and turns its phase as it goes, and a turn of half a circle partway through the prefix makes the second part of the sum cancel the first. &lt;code&gt;body_prefix_correlation()&lt;/code&gt; sums each 32-chip block on its own and adds the nine magnitudes instead. A block lasts 13 ms, short enough for the channel to hold still, and a magnitude is the same whichever way its block points. Step 3, &lt;code&gt;fading_prefix()&lt;/code&gt;, shows the difference on the reference itself, clean and then with the last 128 chips turned 180 degrees, as a fade can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                            one sum   nine blocks
clean prefix                 1.000       1.000
last 128 chips turned 180    0.111       1.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One sum keeps only the difference between the two parts: 160 chips against 128, 32 out of 288. The nine-block sum is unchanged. On a channel that holds still, the two are the same.&lt;/p&gt;

&lt;p&gt;The block sum has a cost. Where nothing matches, the products are random, and a sum of random products grows only like the square root of its length. Nine short sums, their magnitudes added, grow faster than one long one, so away from a match the block correlation sits on a higher floor than the single sum would.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;rotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;polar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frequency_radians&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;as_real&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;coherent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;block_correlation&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;input_energy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;channel_symbol_chips&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;noncoherent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block_correlation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;block_correlation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rotation&lt;/code&gt; takes the carrier offset out of the reference before it is compared; the next section is where the offset comes from. The one long sum, &lt;code&gt;coherent&lt;/code&gt;, is still kept: its angle is the carrier phase at the start, which the equalizer starts from.&lt;/p&gt;

&lt;h2&gt;
  
  
  A match at every segment
&lt;/h2&gt;

&lt;p&gt;Step 4, &lt;code&gt;correlation_comb()&lt;/code&gt;, runs the whole test: the noisy recording, through the front end, correlated at every start from 300 symbols before the burst to the end of its third segment. It lists every start inside the 120 Hz carrier gate that reaches a correlation of 0.35, with the carrier offset estimated there; starts are counted from the burst's first symbol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 dB SNR, carrier +37 Hz off; every start that reaches the 0.35 floor:
  start  correlation  carrier (Hz)
   -256        0.354         +46.6
   -160        0.362         +29.1
    -96        0.396         +58.1
      0        0.959         +35.2
    480        0.959         +36.2
    960        0.965         +37.6
largest correlation anywhere else, inside the carrier gate: 0.308, at start 938
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A match every 480 symbols, one at the start of each segment, at 0.96, each with its own estimate of the 37 Hz offset. Between them, nothing reaches 0.35; the largest is 0.308.&lt;/p&gt;

&lt;p&gt;The three weak ones before the burst are the burst too. A start 96 symbols early, three channel symbols, lays the last six blocks of the reference over the burst's first six. The prefix repeats itself (0, 1, 3, then 0, 1, 3 again), so three of those blocks carry the same channel symbol in both, and three out of nine blocks match. At 160 and 256 symbols early, fewer blocks overlap and fewer match. These are real partial matches, about a third of a full one, so a floor high enough to take them out would also lose weak bursts. Reading the mode and the countdown takes them out (Step 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  The carrier offset
&lt;/h2&gt;

&lt;p&gt;Two single-sideband radios never tune to exactly the same frequency, so the signal arrives some hertz above or below where it left, and every symbol comes out of the front end turned a little further than the one before. The serial-tone section of MIL-STD-188-110B holds the transmitter's own carrier to 1 Hz (5.3.2.3.9b), but says nothing about how large an offset a receiver must accept. Its Appendix C sets a Doppler test for the higher-rate waveforms at 75 Hz (C.6.3), and this modem accepts up to 120 Hz.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;estimate_body_prefix_frequency_radians()&lt;/code&gt; measures the offset over the prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;differential&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;differential&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each term is a received symbol times the conjugate of the one before it, with the known modulation of both taken out. What is left in each term is the turn from one symbol to the next: the offset, in radians per symbol. The terms are added before taking the angle, so the noise averages out over 287 of them. The estimate needs neither the carrier phase nor the correlation, so the scan can run it first.&lt;/p&gt;

&lt;p&gt;An angle is only known modulo a full turn, so the estimate wraps at half a turn per symbol: 1200 Hz either way at 2400 symbols a second. Step 5, &lt;code&gt;carrier_range()&lt;/code&gt;, turns a clean segment by each offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  true (Hz)  estimated (Hz)  inside the 120 Hz gate
       +0.0            +0.0  yes
      +37.0           +37.0  yes
      -75.0           -75.0  yes
     +110.0          +110.0  yes
     +150.0          +150.0  no
     +600.0          +600.0  no
    +1300.0         -1100.0  no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gate, &lt;code&gt;maximum_frequency_offset_hz&lt;/code&gt;, is the scan's first test. It is the cheapest of three, and noise turns by random amounts, so most starts in noise fail it before the correlation is computed at all.&lt;/p&gt;

&lt;p&gt;In Step 4, one segment's estimate came out between 35.2 and 37.6 Hz at 10 dB. That is close enough to start from; Part 3's carrier tracker takes up the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the mode and the countdown
&lt;/h2&gt;

&lt;p&gt;A start that passes the carrier gate and the correlation floor is a candidate. &lt;code&gt;characterize_body_candidate()&lt;/code&gt; reads its D1, D2, C1, C2 and C3: for each, it rebuilds all eight channel symbols' chips, randomizer included, correlates each with the received block, and takes the largest magnitude. The eight patterns are orthogonal, so only the right one correlates fully, and the magnitude makes the decision independent of the carrier phase. (It also adds the strongest copy of the same block up to 15 symbols late, for a two-path channel whose first path has faded; that is next week's subject.)&lt;/p&gt;

&lt;p&gt;Then the checks. Every count symbol is sent as 4 plus two bits, so a count symbol below 4 means the candidate is not the start of a segment. D1 and D2 must name a mode this modem implements, and the count must fit the preamble that mode sends. A candidate that passes is valid, and the arithmetic is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;            &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;elapsed_segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;countdown&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;elapsed_symbols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;elapsed_segments&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;first_segment_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_body_symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detected_segment&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countdown&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;first_segment_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body starts after this segment and the &lt;code&gt;countdown&lt;/code&gt; segments still to come; the preamble started &lt;code&gt;elapsed_segments&lt;/code&gt; before this one.&lt;/p&gt;

&lt;p&gt;Step 6, &lt;code&gt;read_segment()&lt;/code&gt;, walks the scan's tests start by start through the recording until a candidate is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start 940: correlation 0.354, carrier +46.6 Hz, but not a valid segment
start 1036: correlation 0.362, carrier +29.1 Hz, but not a valid segment
start 1100: correlation 0.396, carrier +58.1 Hz, but not a valid segment
first start through all three gates: symbol 1196 (the burst starts at 1196)
  correlation 0.959, carrier +35.2 Hz
  D1 4, D2 6: 600 bps, long interleave
  countdown 23: the body starts 24 segments on, at symbol 12716 (really 12716)
  so the preamble began at symbol 1196
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three starts that fail are Step 4's early partial matches: they passed the floor, and their blocks read as nothing the standard sends. The first valid one is the burst's first symbol, and the countdown puts the body exactly where it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Waking up mid-preamble
&lt;/h3&gt;

&lt;p&gt;The long preamble lasts 4.8 seconds, so a receiver can easily start listening in the middle of one. Step 7, &lt;code&gt;wake_mid_preamble()&lt;/code&gt;, cuts the recording 10 segments and 137 symbols into the burst and runs the whole acquisition on what is left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the receiver starts 4937 symbols into the burst (segment 10, symbol 137)
  it read the countdown from burst segment 13: 10, so 11 segments to the body
  body starts at symbol 6583 of what it heard (really 6583)
  preamble start in what it heard: no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The countdown does not depend on where the receiver started. Acquisition reports that the preamble began before the recording did, so nothing downstream counts on having all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A mode this modem does not implement
&lt;/h3&gt;

&lt;p&gt;The standard is explicit: "If a demodulator receives any D1, D2 combination that it does not implement, it shall not synchronize but shall continue to search for synchronization" (5.3.2.3.7.2.1). Table XV lists D1 7, D2 7 as 2400 bps secure voice, which this modem does not implement, and a note reserves 5, 6 and 5, 7 for application-specific use. Step 8, &lt;code&gt;unsupported_mode()&lt;/code&gt;, builds a 600S preamble, then the same preamble with D1 and D2 changed to 7, 7 in every segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;D1 6, D2 6 (600S):        supported                     acquired: yes
D1 7, D2 7 (patched in):  recognized, not implemented   acquired: no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;recognize_body_designators()&lt;/code&gt; distinguishes the two cases: a combination the standard defines but this modem does not implement, and one the standard does not define at all. Either way the candidate is not valid, nothing is acquired, and the scan goes on.&lt;/p&gt;

&lt;h2&gt;
  
  
  One segment, or all of them
&lt;/h2&gt;

&lt;p&gt;That is everything acquisition needs from one segment. A segment is 200 ms of a weak signal, and one decode at low signal-to-noise can be wrong. The burst sends the same information in every segment, so &lt;code&gt;acquire_body_preamble()&lt;/code&gt; reads all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scan_for_consensus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol_rate_input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search_starts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"body preamble countdown not acquired"&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;strongest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;strongest_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="n"&gt;representative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;vote_mode_and_boundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol_rate_input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strongest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scan_for_consensus()&lt;/code&gt; takes every start through the three gates: the carrier offset, the correlation, and a valid read. Each valid candidate names a mode and a body boundary, and candidates that name the same ones are the same burst, read through different segments, so they join one group. &lt;code&gt;strongest_group()&lt;/code&gt; takes the group with the most segments behind it; a tie goes to the larger sum of correlations. The group's representative, which supplies the carrier offset and phase the later stages start from, is its best-correlated member, not its earliest: an early segment that clears the floor during a fade can carry an estimate several hertz off, and starting the equalizer from it can cost a block that a cleaner segment of the same group would have decoded. Then &lt;code&gt;vote_mode_and_boundary()&lt;/code&gt; reads every segment from the winner's on and lets the majority correct the mode and boundary, if at least two segments agree on something else. When the scan has covered the whole preamble, the strongest group already is the majority; the vote matters when the scan has seen only the first segment or two, as it has in a streaming receiver that commits early.&lt;/p&gt;

&lt;p&gt;Step 9, &lt;code&gt;segment_vote()&lt;/code&gt;, turns the noise up to 4 dB above the signal, reads each segment of the recording on its own, and groups the reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-4 dB SNR, each segment read on its own; the ones that did not read right:
segment  correlation  countdown  reads as
      0        0.257          -  not a valid segment
      3        0.505          -  not a valid segment
      8        0.485          -  not a valid segment
     10        0.434          -  not a valid segment
     15        0.218          -  not a valid segment
     16        0.243          -  not a valid segment
     18        0.493          -  not a valid segment
     20        0.378         19  600 bps, body at 20396  (wrong)
     21        0.410          -  not a valid segment
right 15, wrong 1, not valid 8
grouped by the burst they describe:
  600 bps, body at 12716: 15 segments
  600 bps, body at 20396: 1 segment
acquisition: right
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segment 20 read its countdown as 19 instead of 3, which would have put the body 3.2 seconds late. Alone it is a group of one; fifteen segments agree on the right answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  No burst at all
&lt;/h2&gt;

&lt;p&gt;A receiver spends most of its time listening to noise, and every false acquisition costs something: a mode and a boundary the stages after it try to decode, until they give up. Step 10, &lt;code&gt;noise_only()&lt;/code&gt;, runs ten seconds of white noise through the front end and the whole acquisition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 s of noise: largest correlation 0.264 against the 0.35 floor
  starts through both gates 0, of those reading as a valid segment 0; acquired: no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BodyAcquisitionConfig&lt;/code&gt; sets the correlation floor at a cautious 0.75 by default. This walkthrough uses 0.35, the floor the streaming receiver later in the series runs at, because the floor is only the second of three gates. A start in noise has to turn by under 120 Hz, then correlate above 0.35, then read as a segment of a mode this modem implements, with a count that fits; and to win, it has to outnumber the segments of any real burst. In ten seconds of noise nothing passed the first two.&lt;/p&gt;

&lt;p&gt;How often acquisition finds a burst at each signal-to-noise ratio, and how often noise alone gets through, are measurements of their own, over many recordings; they come in a later article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3's hang-up, fixed while acquiring
&lt;/h2&gt;

&lt;p&gt;Part 3 found one start its timing loop handles badly. A loop started half a symbol off the symbol peaks reads early and late samples of equal strength, so its error is close to zero and it barely moves; on the short preamble it can still be off the peaks when the body begins. Part 3 left that as a documented limit, with a &lt;code&gt;[!shouldfail]&lt;/code&gt; test. The fix belongs to acquisition, because only while the receiver looks for a burst can the loop be anywhere in the symbol; once it has locked to one, the loop is on the peaks.&lt;/p&gt;

&lt;p&gt;The fix is a phase search: the energy comparison a batch receiver makes over a whole recording, made with the three points the loop already reads. Over every 120 symbols the front end adds up the energy of the on-time samples and the energy half a symbol off them. A loop on the peaks reads more energy on time: half a symbol off, where two neighbouring pulses overlap, a stream of raised-cosine pulses at the receive filter's 0.35 rolloff reads about 0.83 of its on-time energy. A loop hung half a symbol off reads more energy off time. When the off-time sum beats the on-time sum by 6 percent in two windows running, the loop is moved half a symbol, onto the peaks, and pulls in from there. It waits for two windows so that noise, whose two sums differ by a few percent, never moves a locked loop. The same comparison speeds the loop up while it is well off the peaks: after a window whose off-time energy reached 92 percent of the on-time energy, the next window runs at three times the tracking gain. In &lt;code&gt;BodyAudioStreamFrontend::process()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;        &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;phase_move&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mPhaseSearch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;mPhaseOnEnergy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_time&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;mPhaseOffEnergy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;early&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;late&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;off_time_points&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;mPhaseWindowSymbols&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;phase_search_window_symbols&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;mPhaseWindowsOff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mPhaseOffEnergy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mPhaseOnEnergy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;phase_search_margin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;mPhaseWindowsOff&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;mPhaseFastGain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mPhaseOffEnergy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mPhaseOnEnergy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;phase_search_fast_gain_ratio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mPhaseWindowsOff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;phase_move&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;as_real&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;half_symbol_samples&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;mPhaseSearchMoves&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="n"&gt;mPhaseWindowsOff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="n"&gt;mPhaseOnEnergy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;mPhaseOffEnergy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;mPhaseWindowSymbols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;loop_gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mPhaseSearch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;mPhaseFastGain&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;phase_search_loop_gain&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mTimingLoopGain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver turns the search on while it looks for and acquires a preamble, and off once it commits to a burst, so a locked body is never moved. Off, the loop is Part 3's loop exactly. Step 11, &lt;code&gt;phase_search()&lt;/code&gt;, starts a 2400S burst at each of the twenty sample phases of a symbol, at 10 dB, with and without the search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2400S, 10 dB SNR, the burst started at each of the 20 sample phases of a symbol
pulled in before symbol 1200 of the 1440-symbol preamble: 17 of 20 plain, 20 of 20 with the phase search
the starts the plain loop missed:
lead  plain loop pulled in at   with the search   moves
   0     not in the preamble               245       1
   1                    1328               379       1
  19     not in the preamble               431       1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the search, three starts around half a symbol off missed symbol 1200 of the 1440-symbol preamble, and two of them were not on the peaks anywhere in the preamble. With it, all twenty were; the three late ones were moved once each and were on the peaks by symbol 245 to 431. Part 3's &lt;code&gt;[!shouldfail]&lt;/code&gt; test runs the front end alone, without the search, so it still fails as expected: the fix lives where the receiver acquires, and this article's tests check it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clock offset, or a second loop
&lt;/h2&gt;

&lt;p&gt;Part 3 ended on what its timing loop cannot do. Two sample clocks never agree exactly, so the received symbols arrive slightly faster or slower than 20 samples apart, and part of the timing error is steady. The loop is proportional: each symbol it moves by its gain times the error. To keep up with a steady drift it needs a steady error, so it settles off the symbol peaks by the drift divided by its gain. At 100 ppm the drift is 0.002 samples a symbol; with the loop's gain of 0.08 and the detector's slope near the peak, that is about 0.74 samples. At 1000 ppm it would need ten times as much, which is past the peak of the detector's output, and the loop cannot hold it.&lt;/p&gt;

&lt;p&gt;Three more steps measure that, and what fixes it. The bench now records where the loop reads every symbol and compares it with the grid the transmitter laid down, stretched by the clock offset (&lt;code&gt;grid_offsets()&lt;/code&gt; in the test harness), so these numbers are the loop's own position, not a count of symbol decisions. Step 12, &lt;code&gt;clock_offset()&lt;/code&gt;, runs 2400 bps bursts carrying 10 s of data each, at 20 dB. The interleaver pads a body to whole blocks, so the long-interleave body lasts 14.4 s and the short one 10.2 s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20 dB SNR, 10 s of data in each burst; the loop against the symbol grid through the body
clock (ppm)  mode   loop    offset (samples)  wander  symbols slipped  integrator at the end
       +100  2400L  type 1             +0.68    0.22                0                      -
       -100  2400L  type 1             -0.64    0.21                0                      -
      +1000  2400L  type 1             +1.47   11.46               30                      -
       +100  2400L  type 2             +0.02    0.22                0             -3 ppm off
      +1000  2400L  type 2             +0.03    0.24                0             -6 ppm off
      -1000  2400L  type 2             +0.03    0.20                0             +1 ppm off
      +1000  2400S  type 2             +0.07    5.98                0            -11 ppm off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three rows are Part 3's loop, type 1. At 100 ppm either way it sits 0.64 to 0.68 samples off the peaks, on the side the clock pushes it. At 1000 ppm it slips 30 symbols through the body. The count starts a segment into the body, and from there the clock drifts by 34 symbols, so the loop barely follows the clock.&lt;/p&gt;

&lt;p&gt;The fix is a second loop with an integrator, the same structure as Part 3's carrier tracker. The integrator adds up the error, so it settles at the clock's rate, and the proportional term is left with only the jitter. That makes it a type-2 loop. In &lt;code&gt;BodyAudioStreamFrontend::process()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mTimingIntegralGain&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;mTimingRateMode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;TimingRateMode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Type 2: the integrator carries the clock's rate, and the proportional term only&lt;/span&gt;
            &lt;span class="c1"&gt;// the jitter. It integrates while tracking; frozen, the rate stands. Both are&lt;/span&gt;
            &lt;span class="c1"&gt;// limited to the step limit, which is far beyond any real clock offset.&lt;/span&gt;
            &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;normalized_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timing_error&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;normalization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mTimingRateMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;TimingRateMode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;tracking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;mTimingRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mTimingRate&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mTimingIntegralGain&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;normalized_error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;mTimingRateSum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;round_to&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;int64_t&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mTimingRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;timing_rate_mean_scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;mTimingRateSamples&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="n"&gt;correction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loop_gain&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;normalized_error&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mTimingRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Type 1, the default, and the tracker while held: the proportional loop.&lt;/span&gt;
            &lt;span class="n"&gt;correction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loop_gain&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;timing_error&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;normalization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mMaximumTimingStep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The integrator is off unless &lt;code&gt;configure_timing_tracker()&lt;/code&gt; turns it on, and with it off, or held, the loop is Part 3's expression exactly, so every Part 3 number stands. In Step 12's type-2 rows it runs from the first preamble candidate to the end of the burst. On the long preamble it ends within 6 ppm of the clock, at 100 and at 1000 ppm either way, and the loop sits within 0.03 samples of the peaks without a slip. On the short preamble at 1000 ppm the loop is still pulling in when the body begins, so it wanders 6 samples through the body, but it does not slip.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to learn, and when to stop
&lt;/h3&gt;

&lt;p&gt;An integrator left running is only right while it is fed a signal. On a line with no signal, the timing error is not zero on average, and an integrator running through minutes of noise walks to a rate the next preamble cannot pull in from. So the receiver holds it at zero while it searches, and starts it at the first preamble candidate acquisition finds.&lt;/p&gt;

&lt;p&gt;Through a fade it causes trouble too. A fading path changes the shape of the received pulse, so the timing error carries a bias that follows the path gains, and the integrator turns that bias into a rate. A clock offset is a crystal's and does not change during a burst, so the first design I measured learned the rate in the preamble and froze it for the body: track from the first candidate, and once 2880 symbols and the end of the preamble had passed, freeze. On the front end alone, at 20 dB, it worked. In the receiver's qualification it did not. The qualification runs the whole receiver, decoding, on white noise through the test bench's channel at the signal-to-noise ratio where each mode is meant to work, many times per condition, each run with its own seed: the number that starts the random-number generator, so a run's noise and fading can be repeated exactly. At 1000 ppm either way, on the short-interleave modes, the receiver with the freeze lost about two thirds of the bursts that the receiver with the integrator left running decoded. Step 13, &lt;code&gt;learning_time()&lt;/code&gt;, shows why. It runs the integrator on 600 bps with the short interleave and the clock 1000 ppm off, at falling signal-to-noise ratios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600S, the clock +1000 ppm off, the integrator running from the first candidate, three seeds
SNR (dB)  rate error at 2880 symbols (ppm)  within 100 ppm from symbol
      20                    +1   -30   -34          2627   2735   2715
      10                  +391  +207   -37          4127   4102   3859
       7                    -6   -52  +213          4091   4049   6266
       4                  +845  +580  +835          4401   6124   8606
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 20 dB the integrator is within 100 ppm of the clock about 2700 symbols after the first candidate, inside the 2880-symbol window. At 4 dB it takes 4400 to 8600 symbols, and when the window closes it is still 580 to 845 ppm off. The timing error is divided by the energy of the three samples it reads, and at 4 dB much of that energy is noise, so each symbol moves the loop and the integrator less. A fixed window that fits 20 dB freezes a wrong rate near the modes' working SNR.&lt;/p&gt;

&lt;p&gt;Leaving the integrator running has a cost of its own on long messages. Through pathsim, on a slow fade (two paths 1 ms apart with a 0.5 Hz fading bandwidth) at the mode's Table 10.1 signal-to-noise ratio, the running integrator lost every one of a set of five-minute 75 bps messages, where the type-1 loop lost at most one; the test bench's own channel gave the same picture. And on white noise at each mode's Table 10.1 signal-to-noise ratio, the type-1 loop alone decoded with the clock up to 250 ppm off, though 75 bps at +250 ppm was marginal, and it lost every long-interleave mode at 500 ppm. MIL-STD-188-110B holds signaling rates to 0.01 percent of nominal (4.2.1), so two modems within the standard are at most 200 ppm apart: the integrator is for clocks worse than the standard allows.&lt;/p&gt;

&lt;p&gt;So the receiver judges the integrator rather than freezing it. It tracks from the first candidate; at 20, 30 and 40 seconds it takes the mean of the integrator's rate since 5 seconds, when the pull-in is over, and if that mean is below a threshold rising from 125 to 175 ppm, it retires the integrator: holds it at zero, the type-1 loop, for the rest of the burst. An integrator that passes the last judgement runs to the end, and a burst over before 20 seconds keeps it throughout. The front end keeps the sum in fixed point, the rate times 2^20 per symbol, so 10^5 symbols lose nothing to float rounding. Step 14, &lt;code&gt;long_burst_rule()&lt;/code&gt;, shows why the judgement is made on the mean. It runs 25 s of 2400L data, a 28.8 s body, through the same slow fade, with no clock offset and with 350 ppm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2400L, 25 s of data through Watterson fading, 1 ms and 0.5 Hz, 20 dB SNR; the integrator from 5 s to 20 s
clock (ppm)  seed  largest momentary rate  mean rate  at 20 s (threshold 125 ppm)
         +0     1                 712 ppm     27 ppm  retired
         +0     2                 643 ppm     83 ppm  retired
       +350     1                 837 ppm    323 ppm  kept
       +350     2                 924 ppm    265 ppm  kept
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through the fade the integrator's momentary rate swings to 640 to 920 ppm whether there is a clock or not; the strongest path moves, and the integrator follows it. Over 15 seconds the swings average out: 27 and 83 ppm with no clock, 265 and 323 ppm with one. The mean tells them apart where the momentary rate cannot.&lt;/p&gt;

&lt;p&gt;One case none of this fixes. When two fading paths are about as strong as each other, the loop can walk from one to the other, a few samples apart, whatever rate it holds. That is the equalizer's problem, and it comes with the equalizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing, part one: what the standard asks, and how long it takes
&lt;/h2&gt;

&lt;p&gt;This article starts a testing thread that runs through the rest of the series, a piece in each article, to a full treatment of compliance testing in Part 9.&lt;/p&gt;

&lt;p&gt;MIL-STD-188-110B sets the serial-tone performance in 5.3.2.5, Table XX: a coded bit-error rate at a signal-to-noise ratio measured in a 3 kHz band, on Watterson-model channels per ITU-R F.520-2 (for the fading rows, two independent fading paths of equal average power), with the longest interleaver. The JITC conformance test plan (2004) turns that into the test conditions of its Table 10.1 (Subtest 10), and its criteria say how long: at least an hour on the non-fading channel, and at least five hours on each fading one.&lt;/p&gt;

&lt;p&gt;The hours come from the statistics. &lt;strong&gt;A bit-error rate of 10^-5 is one error in 100,000 bits.&lt;/strong&gt; With no errors in n bits, the 95 percent upper bound on the rate is about 3/n, so showing 10^-5 takes about 300,000 error-free bits: two minutes at 2400 bps, over an hour at 75 bps. That bound assumes the errors are independent, and a decoder's are not: when it fails, it fails over a run of bits, so for a coded modem the bound is optimistic. A fading channel needs longer still, because the errors come in the deep fades, which are rare; the test has to run long enough to meet a great many of them.&lt;/p&gt;

&lt;p&gt;None of that tests acquisition directly. The serial-tone section sets no acquisition requirement, and Appendix C's acquisition test, for the higher-rate waveforms, reads "Not yet standardized" (C.6.2). The acquisition tests in this article are mine: every mode found, the carrier gate, waking mid-preamble, a mode not implemented, and nothing in ten seconds of noise.&lt;/p&gt;

&lt;p&gt;A test result is only as good as the channel it ran through, so this work uses three channel simulators, and measures each before trusting its numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The test bench's own model&lt;/strong&gt;, inside the unit tests: a two-path Watterson model, an engineering model. Its noise and fading come from a random-number generator started from a seed, so two designs run on the same seeds meet exactly the same noise and the same fades, and any difference between them comes from the designs, not from luck. That makes it good for comparing designs, as Steps 12 to 14 do, and not for absolute numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pathsim&lt;/strong&gt; (&lt;a href="https://github.com/bubnikv/pathsim" rel="noopener noreferrer"&gt;github.com/bubnikv/pathsim&lt;/a&gt;): Moe Wheatley AE4JY's PathSim, reworked by Vojtech Bubnik OK1IAK, under the GPL-3.0. The seeded campaigns run on it; my build adds a seed, so a run repeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The IONOS HF channel simulator&lt;/strong&gt; (&lt;a href="https://github.com/ARSFI/HFSimulator" rel="noopener noreferrer"&gt;github.com/ARSFI/HFSimulator&lt;/a&gt;): a hardware box by Rick Muething KN6KB, Tom Lafleur KA6IQA and Tom Whiteside N5TW, from the Amateur Radio Safety Foundation, under the MIT license. It puts a real audio path between the transmitter and the receiver, so it checks the software simulators independently. Measured the same way as pathsim, it delivered more signal-to-noise ratio than its setting on a non-fading channel, by an amount that varied with the signal level, and its two-path settings stop at 4 ms and 2 Hz, short of the 5 ms, 5 Hz channel Table 10.1 sets for the lowest rates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A commercial laboratory channel simulator costs many thousands of dollars. Whichever kind is used, MIL-STD-188-110D's Appendix E applies to a simulator used to test a modem's compliance, and says what it should meet: a frequency response within 0.5 dB; a signal-to-noise ratio within 0.25 dB of its setting on a non-fading path, averaged over at least 20 minutes, and within 0.5 dB on fading paths, averaged over at least two hours; and a Doppler spectrum measured from at least three hours of a tone (E.7). I ran the same kinds of measurement, shorter than the appendix asks, with the same probes on every simulator.&lt;/p&gt;

&lt;p&gt;Two other software modems decode the same captured audio beside this one: MS-DMT, N2CKH's free, closed-source 110A modem, and ION2G, another 110A software modem. They are a second opinion on the test set-up, not a reference for this receiver: when all three decode a clean burst exactly, the audio path is good, and a disagreement points at the set-up before it points at a decoder.&lt;/p&gt;

&lt;p&gt;The test audio comes from my waveform generator and signal lab. They render a recording with CW, noise, impulses, fades and sample slips mixed in, play it from the PC or from a microcontroller board that is also a USB sound card, capture what comes back, and keep a journal to replay any run. They will be public, under the GPL-3.0, soon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/acquisition_tests.cpp&lt;/code&gt; runs every walkthrough step and checks what it returned: the 24 segments and their countdown, the reference against every segment, the block sum through the phase flip, the matches at the segment starts and nowhere else, the estimate to 1200 Hz and the gate at 120 Hz, the mode, the countdown and the body boundary, the wake, the mode not implemented, the vote, and the noise. The phase-search test checks that every start phase pulls in within the short preamble with the search on. The clock steps' tests check the loop against the grid: type 1 off the peaks by about the drift over its gain at 100 ppm and slipping at 1000, type 2 on the peaks with no slip; at 4 dB the integrator still hundreds of ppm off when 2880 symbols have passed; and on the long burst, the mean telling no clock from a real one. Around them are tests beyond the walkthrough: every mode the modem implements, at 10 dB with the carrier 75 Hz off either way, found with the right mode, start and body; the gate, which takes a burst 110 Hz off and not one 150 Hz off; and waking at five places in a long preamble, down to the last whole segment.&lt;/p&gt;

&lt;p&gt;The test bench grew with them. The test channel takes a carrier offset, in whole hertz, so the shift is exact over any length of audio, the way the CW tone is. Its clock offset came back too, fixed: the first version kept the resampler's position in one &lt;code&gt;float&lt;/code&gt;, which past a fraction of a second could no longer take the offset's tiny step, so a whole burst got the offset only at its start. It now keeps a whole sample plus a fraction, the way the front end keeps time. And the bench has its own answer for where a burst sits in the front end's output, &lt;code&gt;locate_burst()&lt;/code&gt;: it lines the burst's last 2000 symbols up with what was sent, eight at a time so a turning carrier cannot cancel the sum. Acquisition is checked against that, and never against itself.&lt;/p&gt;

&lt;p&gt;The acquisition code reached this repository rearranged. &lt;code&gt;acquire_body_preamble()&lt;/code&gt; had grown into one function of 400 lines with a lambda inside it, a fix at a time; it is now the short sequence above, one named function a step. Before the rearrangement went anywhere, a scratch program ran the old and the new code on the same inputs, every combination of six modes, four carrier offsets, three signal-to-noise ratios, six channels (one path; two paths at three spacings; two with the first path faded) and three acquisition settings, and compared every field of every result bit for bit. They were identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your own
&lt;/h2&gt;

&lt;p&gt;Each demo walks one stage on data I chose. The runner runs every stage published so far, in order, on what you give it: your message through Part 2's transmitter, a channel you set, Part 3's front end, and this article's acquisition. It prints what each stage found. Each article adds its stage, so it grows with the series.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build/runner &lt;span class="s2"&gt;"Hi"&lt;/span&gt; &lt;span class="nt"&gt;--snr&lt;/span&gt; 10 &lt;span class="nt"&gt;--carrier&lt;/span&gt; 37
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Part 2  transmit: "Hi" in 600L (600 bps, long interleave)
           2 bytes -&amp;gt; 11520 preamble symbols (24 segments) + 11520 body symbols, 9.60 s of audio
== Channel: 10 dB SNR, carrier +37 Hz; 0.5 s of line before the burst, seed 4
== Part 3  front end, Part 3's loop: 10.10 s of audio -&amp;gt; 24232 symbols
           through the body, against the transmitter's grid: 0.04 samples off the symbol peaks, in step with every symbol
== Part 4  acquisition, with the phase search on (it moved the loop 0 times): 600 bps, long interleave
           read at symbol 3116: countdown 19, correlation 0.966, carrier +37.7 Hz
           preamble from symbol 1196, body from symbol 12716 (the bench: 1196 and 12716)
           committed at symbol 1676: the phase search off, the rate integrator tracking
           the clock: the integrator learned 0 ppm through the body
           through the body, against the transmitter's grid: 0.04 samples off the symbol peaks, in step with every symbol
== next: the first path (Part 5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the line from Steps 4 and 6: the same message, noise and seed, so the same burst. Step 6 showed the first segment through the gates, with countdown 23; the runner shows what &lt;code&gt;acquire_body_preamble()&lt;/code&gt; returns, the group's best-correlated segment, here the fifth, with countdown 19. Both put the preamble at symbol 1196 and the body at 12716, where the bench's own answer puts them.&lt;/p&gt;

&lt;p&gt;The runner runs the front end three times over the audio, once for each way a receiver can run it. The Part 3 line is Part 3's loop and nothing else. Acquisition runs on the front end with the phase search on, the way a receiver runs it while it looks for a preamble. Then the runner commits at the first candidate, a segment into the preamble: from there the search is off and the rate integrator tracks, the way a receiver listening live would have run it from that point. (A live receiver does all of that in one pass; the runner has the whole recording, so it finds the candidate first and then runs the front end again.)&lt;/p&gt;

&lt;p&gt;Give the transmitter's clock a 1000 ppm error and the two loops part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build/runner &lt;span class="s2"&gt;"Hi"&lt;/span&gt; &lt;span class="nt"&gt;--snr&lt;/span&gt; 20 &lt;span class="nt"&gt;--clock&lt;/span&gt; 1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Part 3  front end, Part 3's loop: 10.09 s of audio -&amp;gt; 24210 symbols
           through the body, against the transmitter's grid: the loop slipped 11 symbols, net 11 lost
== Part 4  acquisition, with the phase search on (it moved the loop 0 times): 600 bps, long interleave
           ...
           committed at symbol 1675: the phase search off, the rate integrator tracking
           the clock: the integrator learned +1000 ppm through the body
           through the body, against the transmitter's grid: 0.04 samples off the symbol peaks, in step with every symbol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 3's loop slips 11 symbols through the 4.8 s body, every one a symbol stepped over, about what the clock drifts across it, as Step 12's type-1 row does on 2400L. (The runner counts every slip, either way, and gives the net beside it, so a slip one way and one back cannot pass for a loop in step.) With the integrator tracking from the commit, the loop learns the clock and stays on the peaks. Try &lt;code&gt;--fade 2,1&lt;/code&gt; for two-path fading, &lt;code&gt;--mode 2400S&lt;/code&gt;, or a message of your own; &lt;code&gt;--help&lt;/code&gt; lists the rest.&lt;/p&gt;

&lt;p&gt;The runner also reads a WAV file, 48 kHz, 16-bit or float. &lt;code&gt;--save heard.wav&lt;/code&gt; writes the audio it hands the receiver, and &lt;code&gt;--wav heard.wav&lt;/code&gt; runs the receive stages on it; a recording of a real transmission works the same way, with no bench answer to compare against. &lt;code&gt;tests/runner_tests.cpp&lt;/code&gt; checks the chain: the WAV file out and back in, the walkthrough's burst found where the bench put it, a 1000 ppm clock learned, the slip count on a trace that slips one way and back, and a saved recording read back to the same burst.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go read the code, and the standard
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Table XVI lists the two-bit count values in an order that does not match the rule in the text of 5.3.2.3.7.2.1 ("adding a '1' before the two-bit value"). Which reading does &lt;code&gt;characterize_body_candidate()&lt;/code&gt; follow, and how does the note's example, 23 sent as 5, 5, 7, decide it?&lt;/li&gt;
&lt;li&gt;The prefix is the same at the start of every segment because the sync randomizer's sequence is 32 long. What would a receiver have to do if a segment were not a whole number of its periods?&lt;/li&gt;
&lt;li&gt;Work out the block correlation's floor with no match: nine blocks of 32 random unit products, magnitudes added, normalized as &lt;code&gt;body_prefix_correlation()&lt;/code&gt; does. Then work it out for one sum of 288.&lt;/li&gt;
&lt;li&gt;The carrier estimate wraps at 1200 Hz. Add 2500 Hz to Step 5's list: what does it print, and why?&lt;/li&gt;
&lt;li&gt;A receiver hears a long-interleave segment with countdown 10 at symbol 9000 of its recording. Where does the body start, and where did the preamble start?&lt;/li&gt;
&lt;li&gt;The channel-symbol decode compares the eight patterns by magnitude. What would comparing the real part need that the decoder does not have?&lt;/li&gt;
&lt;li&gt;Why does a consensus group's representative have to be its best-correlated member, and not its first?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vote_mode_and_boundary()&lt;/code&gt; needs two segments to agree before it overrules the winner. What could go wrong with one?&lt;/li&gt;
&lt;li&gt;Find the D1, D2 combinations &lt;code&gt;recognize_body_designators()&lt;/code&gt; treats as recognized but not implemented. Which does the standard reserve, and which is a mode?&lt;/li&gt;
&lt;li&gt;Set the walkthrough's floor to 0.25 and run Step 10 again. What gets through the first two gates now, and what stops it?&lt;/li&gt;
&lt;li&gt;Run Step 9 at -8 dB. How many segments read right on their own, and does acquisition still find the burst?&lt;/li&gt;
&lt;li&gt;Work out the type-1 loop's offset at 100 ppm from the drift and the gain, and compare it with Step 12. What sets the slope that turns the gain into samples?&lt;/li&gt;
&lt;li&gt;Step 13: at 4 dB the integrator is still hundreds of ppm off after 2880 symbols, with the same gains as at 20 dB. Look at what the timing error is divided by, and explain why noise slows the loop down.&lt;/li&gt;
&lt;li&gt;Why must the integrator be held at zero while the receiver searches? Run the type-2 loop through Step 10's ten seconds of noise and watch &lt;code&gt;timing_rate()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Step 14 takes the mean from 5 s, not from the first candidate. What would the first 5 s add to the mean at 4 dB, and to the judgement?&lt;/li&gt;
&lt;li&gt;The phase search moves the loop only after two windows in a row read more energy off time. Change it to one, and run Step 11 at 4 dB: what does a loop already on the peaks do now?&lt;/li&gt;
&lt;li&gt;Table 10.1's fading conditions run for at least five hours. Roughly how many fades of a 1 Hz fading bandwidth is that, and why does a fading test need so many more bits than a non-fading one?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Next week: the first path. On a two-path channel, each path delivers its own copy of the preamble, a few symbols apart, and each copy forms its own group. When the two paths are of similar strength, the strongest group is often the later one, the echo, and an equalizer that starts on the echo cannot reach the path in front of it. Earliest-path anchoring finds the first copy, even when it faded through the segments the scan saw first, and moves the anchor to it.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>dsp</category>
      <category>radio</category>
    </item>
    <item>
      <title>The audio front end: finding the symbols</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Fri, 09 Oct 2026 19:10:25 +0000</pubDate>
      <link>https://dev.to/kg7hf/the-audio-front-end-finding-the-symbols-138l</link>
      <guid>https://dev.to/kg7hf/the-audio-front-end-finding-the-symbols-138l</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of "Building a MIL-STD-188-110 HF Modem in Modern C++." Start with the &lt;a href="https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0"&gt;overview&lt;/a&gt;, Part 1, &lt;a href="https://dev.to/kg7hf/the-rules-i-build-by-1dj4"&gt;the rules I build by&lt;/a&gt;, or Part 2, &lt;a href="https://dev.to/kg7hf/the-transmit-chain-the-easy-half-done-right-2540"&gt;the transmit chain&lt;/a&gt;. The code for this article is on GitHub: &lt;a href="https://github.com/kg7hf/modem-core/tree/part-3" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 2 ended with clean symbols on the air. The transmitter knew everything about its signal: the carrier, the symbol rate, and where every symbol starts. The receiver starts with none of that. It gets 48,000 audio samples a second and has to turn them back into symbols: one complex value per symbol, taken at the right instant. That is the job of the audio front end, the first stage of the receiver.&lt;/p&gt;

&lt;p&gt;The standard says what a receiver has to achieve, not how it does it. Everything in this article is an implementation choice, so for each one I show what the code does, why, and the step of the demo that shows it running.&lt;/p&gt;

&lt;p&gt;Part 2 had &lt;code&gt;tx_demo&lt;/code&gt; to walk its message through the transmitter; this article has &lt;code&gt;front_end_demo&lt;/code&gt;. Build it, run it, and keep its output open beside this article, the way Part 2 asked you to keep &lt;code&gt;tx_demo&lt;/code&gt;'s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build/front_end_demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs nine steps in the order this article takes them, and every number below comes out of one of them. Each step is a function in &lt;code&gt;tests/walkthrough/front_end_steps.cpp&lt;/code&gt; that &lt;code&gt;main()&lt;/code&gt; calls by name, and each section below names its step. So the best way to read a section is in the debugger: in VS Code, press F5 and choose &lt;em&gt;front_end_demo (gdb)&lt;/em&gt;. It stops at the top of &lt;code&gt;main()&lt;/code&gt;; step into each step in turn, or set a breakpoint in the function for the section you are reading and continue to it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;carrier_table()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixing down to baseband&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tone_mix()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixing down to baseband&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tribit6_mix()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixing down to baseband&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;receive_filter()&lt;/code&gt;, &lt;code&gt;pulse_neighbors()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The matched filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;timing_error()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Finding the sampling instant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;float_position()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeping time for hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;block_sizes()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any block size, the same symbols&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;carrier_tracking()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Carrier tracking, part one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;code&gt;walk()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Walk the symbols back out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This article also starts the repository's unit tests, and they call the same nine functions: &lt;code&gt;tests/front_end_tests.cpp&lt;/code&gt; runs each step and checks what it returned. So the walkthrough you read is also under automated regression. &lt;code&gt;ctest&lt;/code&gt; runs it all, and the Testing panel in VS Code runs or debugs any one test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four jobs
&lt;/h2&gt;

&lt;p&gt;The front end does four things, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mix down.&lt;/strong&gt; Move the signal from the 1800 Hz carrier to 0 Hz, so each symbol becomes a plain complex number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter.&lt;/strong&gt; Run a matched filter, the receive-side counterpart of the transmitter's pulse shaping, to get the best signal-to-noise ratio at each symbol instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the sampling instant.&lt;/strong&gt; Work out where in the stream of filtered samples each symbol is best read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it there.&lt;/strong&gt; Track that instant as it moves, for as long as the signal lasts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fifth job, removing what is left of the carrier's phase and frequency, starts here and finishes in the equalizer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqn8ld7p295mxiywjppw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqn8ld7p295mxiywjppw.png" alt="The receive front end, top to bottom: mix down with the carrier table, ring buffer, matched filter, read early, on time and late, the early-late timing error, and the timing loop back to the next read; symbols out to acquisition" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixing down to baseband
&lt;/h2&gt;

&lt;p&gt;The audio is real: one number per sample. The symbols are complex: a phase on the 1800 Hz carrier. To get them back, the front end multiplies each sample by a phasor that turns at minus 1800 Hz. The signal lands at 0 Hz, along with a copy at 3600 Hz that the matched filter removes. The product is doubled because a real signal carries half its energy at plus 1800 Hz and half at minus 1800 Hz, and the mix keeps only one half.&lt;/p&gt;

&lt;p&gt;The phasor comes from a table. At 48,000 samples a second, 1800 Hz turns 3/80 of a cycle per sample, 13.5 degrees, so after 80 samples it has made exactly three cycles and starts over. Eighty constants hold the whole carrier, and the mixer reads entry n mod 80 for sample n. It keeps that index as a counter that wraps at 80, because a remainder of the 64-bit sample count would be a library call on a part with no 64-bit divider. Nothing about the carrier is computed while the modem runs, so nothing about it can drift, however long the audio lasts.&lt;/p&gt;

&lt;p&gt;Step 1, &lt;code&gt;carrier_table()&lt;/code&gt;, walks the table. It compares every entry with the exact phasor and prints the first few entries and the quadrants; its test checks that every entry is within half a float step of exact, and that the four quadrant entries are exact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  n   phase (deg)      cos        sin
  0        0.0   +1.000000  +0.000000
  1       13.5   +0.972370  +0.233445
  2       27.0   +0.891007  +0.453990
  3       40.5   +0.760406  +0.649448
  4       54.0   +0.587785  +0.809017
  5       67.5   +0.382683  +0.923880
  6       81.0   +0.156434  +0.987688
  7       94.5   -0.078459  +0.996917
 20      270.0   +0.000000  -1.000000
 40      180.0   -1.000000  +0.000000
 60       90.0   +0.000000  +1.000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2, &lt;code&gt;tone_mix()&lt;/code&gt;, feeds the front end a pure 1800 Hz tone, and a constant comes out: 2.2505, symbol after symbol. A tone at 1810 Hz comes out turning by 0.026179 radians per symbol, which is 10 Hz at 2400 symbols per second, with a faint ripple from the mix's mirror image near 3600 Hz, far down the filter's stopband.&lt;/p&gt;

&lt;p&gt;Then a real symbol. Part 2 ended on the randomizer's sixth value, 6, and its worked symbol was tribit 6, at 270 degrees; in the "Hi" burst they meet at body symbol 5. Step 3, &lt;code&gt;tribit6_mix()&lt;/code&gt;, prints the audio around that symbol's peak, the carrier's phase at each sample, and what the mix makes of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   sample     audio   carrier (deg)   mixed I   mixed Q
   230496   -0.2831          216.0   +0.4580   -0.3328
   230497   -0.4058          229.5   +0.5270   -0.6171
   230498   -0.5029          243.0   +0.4566   -0.8961
   230499   -0.5652          256.5   +0.2639   -1.0991
   230500   -0.5872          270.0   -0.0000   -1.1744
   230501   -0.5693          283.5   -0.2658   -1.1072
   230502   -0.5165          297.0   -0.4689   -0.9203
   230503   -0.4360          310.5   -0.5664   -0.6631
   230504   -0.3384          324.0   -0.5476   -0.3979
after the matched filter: on-time (-0.016, -2.391), 269.6 degrees, tribit 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One mixed sample is not the symbol. It is a real number times the mixing phasor, so it can only point along that phasor, one way or the other; put another way, it is the symbol plus a copy of it turning the other way at 3600 Hz, 27 degrees a sample. At the peak, sample 230,500, the two line up and the mixed sample points straight down. On either side the copy swings it left and right. The matched filter averages over many samples, the turning copy cancels, and what comes out is (-0.016, -2.391): 269.6 degrees, tribit 6. Set a breakpoint in &lt;code&gt;tribit6_mix()&lt;/code&gt; and step through it; the numbers are the ones above.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compiler changed the math
&lt;/h3&gt;

&lt;p&gt;At the end of Part 2 I promised to explain why the transmitter's audio can differ in the last bit between two builds of the same source, even though every symbol matches.&lt;/p&gt;

&lt;p&gt;The transmitter computed the carrier's cosine and sine for every sample. With optimization on, GCC noticed that the code asked for the cosine and the sine of the same angle and replaced the two calls with one &lt;code&gt;sincos&lt;/code&gt; call. On the toolchain I measured, that function rounds a few results differently in the last bit. The symbols are integers, so they never change; the float audio does, by a tiny fraction of the smallest step a 16-bit sample can show.&lt;/p&gt;

&lt;p&gt;IEEE 754, the standard for float arithmetic, requires addition, subtraction, multiplication, division and square root to be correctly rounded, so they give the same bits on every machine that follows it. Sine and cosine have no such requirement. Each library computes them its own way, and the compiler can swap one call for another. No build setting controls that across every library, so the fix is to stop calling them where it matters. Two changes do it, and both are in the code now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The carrier table above, in the transmitter and in the receiver's mixer.&lt;/li&gt;
&lt;li&gt;The build forbids the compiler to fuse a multiply and an add into one instruction (&lt;code&gt;-ffp-contract=off&lt;/code&gt;), because a fused result is rounded once instead of twice, and whether it fuses depends on the compiler and the target.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measuring the change turned up a bigger effect than the one I went looking for. The old transmitter kept the carrier's phase in a &lt;code&gt;float&lt;/code&gt; and added one step to it for every sample. Each addition rounds, and the rounding piles up: by sample 458,610, near the end of the "Hi" burst, the phase had walked about 1.4 degrees from where it belonged. The symbols still came out right, because the receiver tracks the carrier, but it is the same story as the timing position later in this article: a &lt;code&gt;float&lt;/code&gt; that keeps adding walks off. With the table there is no running sum to walk.&lt;/p&gt;

&lt;p&gt;What is left is plain arithmetic, and a new library cannot change a result it never computes. On Linux, with GCC and Clang, in Debug and Release, all four builds now produce the same audio, bit for bit, and the same symbols as before. I have not checked Windows yet, and the filter taps are still computed with the library's cosine when the modem starts, so a different library can still move the last bit of a tap. Making the taps constants too is the next step, and then the golden test can check the whole audio instead of five decimal places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matched filter
&lt;/h2&gt;

&lt;p&gt;The receiver filters with a root-raised-cosine pulse, the same family the transmitter shaped with: 161 taps, eight symbols long. Each symbol is spread across neighboring symbols on the air, and the filter is shaped so that, at each symbol's own instant, the neighbors add almost nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the receive filter is wider than the transmit pulse
&lt;/h3&gt;

&lt;p&gt;The transmitter shapes with a rolloff of 0.25; the receiver filters with 0.35. A matched filter is, strictly, the transmit pulse itself, so this is a mismatch, and nothing in the history records why: the code says only that it copies the filter of an earlier reference receiver. So I measured it both ways on the modem's own signal (a 2400 bps burst through the whole front end, five random seeds per condition, signal-to-noise measured in a 3 kHz band):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signal-to-noise:&lt;/strong&gt; almost no cost. The filter's peak response drops from 0.9997 to 0.9973, about 0.02 dB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interference between symbols:&lt;/strong&gt; a real cost on a clean channel. With the matched 0.25 filter, the largest leftover at a neighboring symbol's instant is 50.5 dB below the peak; with 0.35 it is 43 dB below. On a clean burst the symbol error floor is -42.6 dB with 0.25 and -37.7 dB with 0.35. With noise added the gap shrinks to about a tenth of a dB at 20 dB signal-to-noise, and to nothing at 10 dB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing:&lt;/strong&gt; no measurable difference. The wider filter gives the timing detector a slightly stronger error signal, which shows as a slightly smaller standing offset under a 100 ppm clock error (0.74 against 0.79 samples), but the loop's jitter is the same within the noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behind an equalizer:&lt;/strong&gt; none. With noise or fading and an ideal equalizer after the front end, both filters leave the same symbol error, within 0.2 dB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step 4, &lt;code&gt;receive_filter()&lt;/code&gt; and &lt;code&gt;pulse_neighbors()&lt;/code&gt;, prints the filter and the first two of those numbers for both rolloffs: the transmit pulse through the receive filter, its peak, and the largest leftover at any other symbol's instant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;receive filter: 161 taps, energy 1.000000, peak 0.2450 at tap 80
transmit 0.25, receive 0.25: peak 0.9997, largest neighbor 0.00298 (50.5 dB below)
transmit 0.25, receive 0.35: peak 0.9973, largest neighbor 0.00706 (43.0 dB below)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this evidence the wider filter buys nothing the rest of the receiver needs, and the matched one is cleaner. The modem keeps 0.35 for now: the receiver's conformance results were measured with it, and changing a filter the receiver was qualified with means qualifying it again first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Computing only what is needed
&lt;/h3&gt;

&lt;p&gt;The timing loop reads three points per symbol, and each point is interpolated between two filtered samples. So the front end runs the filter only at those six positions, not at all 20 samples of every symbol: 966 tap products per symbol instead of 3220.&lt;/p&gt;

&lt;p&gt;The raw samples wait in a ring buffer of 1024 entries. The deepest read reaches 661 samples back, and a &lt;code&gt;static_assert&lt;/code&gt; beside the ring checks that the buffer is at least that deep, so a change that outgrows it fails the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the sampling instant
&lt;/h2&gt;

&lt;p&gt;Each symbol has one best instant to read it: the peak of the filtered pulse. Between peaks the signal passes through transitions, where it carries little information about the symbol. At 20 samples per symbol, the peak can fall anywhere between two samples, so the loop keeps a fractional position and reads the filtered signal there by linear interpolation between the two nearest samples.&lt;/p&gt;

&lt;p&gt;The loop decides where to read next with an early-late timing detector. At each symbol it reads three points: half a symbol early, on time, and half a symbol late. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;timing_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;late&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;early&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_time&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the late sample is stronger than the early one, the peak is ahead, and the loop moves later; if the early one is stronger, it moves earlier. Multiplying by the conjugate of the on-time sample makes the result independent of the carrier's phase, so the loop works before the carrier is tracked. The error is divided by the energy of the three samples, so a loud signal and a quiet one move the loop by the same amount, and the step is limited to a fifth of a sample per symbol. So the front end needs no gain control of its own. The stages after it do care about the level, and the receiver scales each burst's symbols to a set level before it hands them on.&lt;/p&gt;

&lt;p&gt;Step 5, &lt;code&gt;timing_error()&lt;/code&gt;, reads the receive pulse at those three points, with the on-time point on the peak and four samples either side of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on the peak +0.000000, 4 samples early +0.031043, 4 samples late -0.031043, early and turned by 1 rad +0.031043
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero on the peak. Reading early, the error is positive, so the loop moves later; reading late, it is the same size with the other sign. And turning the carrier by a radian changes nothing.&lt;/p&gt;

&lt;p&gt;The on-time sample is the symbol. The early sample goes along with it: two samples per symbol, which the equalizer uses in a later article.&lt;/p&gt;

&lt;p&gt;Here's a sea story. Back in the day, when I arrived at US Navy boot camp, we learned to form up into a column, and we marched everywhere as a unit. You've probably seen movies where someone in the group, maybe the drill instructor or the company commander, is "calling cadence". Calling cadence was usually assigned each week as a special duty, a reward. The cadence is rhythmic, and it keeps everyone in sync: left, left, left right left. Then you can throw in some colorful rhymes. It keeps all the steps in sync, and when it doesn't, the column stumbles and falls out of formation, so keeping a good rhythm is important for marching. Well, I was awarded the job of calling cadence, and wow, was I excited. Unfortunately, my internal clock has a lot of jitter; one could say I don't have good rhythm. My cadence lasted about one day of lllleft, rrrright, rrright, leeft, all at random times, and everyone's feet were jumbled up trying to get onto the foot I was calling at the time. Needless to say, the formation lost sync and collapsed, and my day of calling cadence ended when I was replaced by a more stable oscillator.&lt;/p&gt;

&lt;p&gt;A timing loop has the column's problem. Every symbol, the detector calls the beat, and noise makes some calls early and some late. If the loop jumped to each call, its samples would stumble the way my column did, so it moves only a fraction of what each call asks for, at most a fifth of a sample per symbol, and keeps a steady step through the noise. How steady it stays is its jitter, and jitter is where the two detectors below differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  Early-late or Gardner
&lt;/h3&gt;

&lt;p&gt;The overview called this Gardner symbol timing. The detector in the code is the early-late form. The two are related; here is how they differ.&lt;/p&gt;

&lt;p&gt;Both are textbook detectors, and both multiply a difference by a third sample:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Early-late:&lt;/strong&gt; the difference of the two samples half a symbol either side of this symbol, weighted by the on-time sample. It looks for the peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gardner&lt;/strong&gt; (Floyd Gardner, 1986): the difference of this symbol's on-time sample and the previous one, weighted by the sample halfway between them. It looks for the zero crossing between two symbols.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I measured both on the modem's own pulses. On average they behave the same: the same zero point, the same strength. Symbol by symbol they do not. Gardner's output is steadier: on a clean signal its spread is under half of early-late's, the gap narrows as noise rises, and by a 5 dB signal-to-noise ratio they are close. Gardner is also cheaper here, because the previous symbol's on-time sample is already computed: two interpolations per symbol instead of three.&lt;/p&gt;

&lt;p&gt;On a dirty channel the difference does not show in the symbols. I ran both loops over the same transmitted bursts (2400 bps, long interleave) with five fixed random seeds per condition: noise at 20, 10 and 6 dB signal-to-noise in a 3 kHz band, a CW tone, impulse noise, a strong signal in the adjacent channel, two-path multipath, Watterson fading, and clock offsets. Wherever both loops held lock, Gardner's timing jitter was a quarter to a half lower, yet the symbol error came out within a few tenths of a dB either way. Interference cost both the same: a CW tone 10 dB below the signal cost nothing extra, and one as strong as the signal ruined both. Multipath and fading hurt through the echoes, which is the equalizer's job, and after an ideal equalizer the two were indistinguishable. (The test kit's fading model is not conformance-qualified, so those rows are engineering evidence, not conformance results.) At a 1000 ppm clock offset both lost lock: the limit there is the loop, not the detector, and that is next week's subject.&lt;/p&gt;

&lt;p&gt;Why does the code use early-late, then? There was no decision. The loop arrived labelled "Gardner-style", and the label stayed until I checked the formula for this article. For now the modem keeps early-late. Its conformance results were measured with it, and the front end shows no symbol-quality reason to switch; if I do switch, it will be because the full receiver's conformance runs say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping time for hours
&lt;/h2&gt;

&lt;p&gt;The overview and Part 1 both mentioned a sample counter that walks off the grid after about twenty minutes. This is that story.&lt;/p&gt;

&lt;p&gt;The timing loop's position is a sample count, and it grows for as long as the audio does. A &lt;code&gt;float&lt;/code&gt; holds 24 bits of precision. Past 2^24 samples, about six minutes of audio, it can no longer hold the fraction of a sample the loop needs. Past 2^26 samples, about 23 minutes, it cannot even add the 20 samples of one symbol: 67,108,864 plus 20 comes out as 67,108,880, a step of 16. The loop slides four samples per symbol, and a long capture decoded at chance.&lt;/p&gt;

&lt;p&gt;The whole-buffer version of the loop fixed it with a &lt;code&gt;double&lt;/code&gt; position, which is exact for longer than anyone will record. The streaming front end, the one built for the microcontroller, splits the position into a whole number of samples (a 64-bit integer) and a fraction (a &lt;code&gt;float&lt;/code&gt; between 0 and 1). The fraction keeps its full precision forever, and there is no &lt;code&gt;double&lt;/code&gt; on the M33, where it would be emulated in software.&lt;/p&gt;

&lt;p&gt;Step 6, &lt;code&gt;float_position()&lt;/code&gt;, shows both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;float: 2^26 + 20 = 67108880.0; 2^24 + 20.5 = 16777236.0
whole + fraction: 67108885 + 0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it holds over a long run. One test in the bench, "Thirty minutes of continuous traffic: still exact past 2^26 samples", sends 188 bursts through one front end, 86,630,400 samples, well past 2^26, and every decision in every burst comes out right. It takes a few seconds, so it runs only when asked for: &lt;code&gt;ctest -L long&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Any block size, the same symbols
&lt;/h2&gt;

&lt;p&gt;On a microcontroller the audio arrives in whatever blocks the DMA or the interrupt hands over. The streaming front end keeps all of its state between calls: the ring, the carrier table's index, the timing position. So the symbols do not depend on how the audio is cut up: feed it one sample at a time or 480 at a time, and the output is identical, bit for bit. The carrier index is part of that state for the same reason: it counts samples, not blocks.&lt;/p&gt;

&lt;p&gt;Step 7, &lt;code&gt;block_sizes()&lt;/code&gt;, checks it: the "Hi" burst goes through one front end 480 samples at a time and through another in blocks of 1, 7, 33, 160 and 480 samples in turn, and all 23,032 symbols come out identical, bit for bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carrier tracking, part one
&lt;/h2&gt;

&lt;p&gt;Two radios never agree exactly on 1800 Hz, and the path between them can shift it further. After the mix, that difference shows up as a slow rotation of every symbol. The carrier tracker removes it with a second-order loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It turns each incoming symbol back by its current phase estimate.&lt;/li&gt;
&lt;li&gt;It measures the remaining angle against a decision, the constellation point the symbol should be.&lt;/li&gt;
&lt;li&gt;It adds a fraction of that angle to its frequency estimate (the integral part), and steps its phase by the frequency plus another fraction of the angle (the proportional part).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frequency estimate is limited to 0.1 pi radians per symbol, 120 Hz at 2400 symbols per second. The decisions it learns from come from the equalizer, in a later article.&lt;/p&gt;

&lt;p&gt;Step 8, &lt;code&gt;carrier_tracking()&lt;/code&gt;, uses the receiver's gains. Given symbols turned by a fixed 0.5 radians, it learns 0.5000; given symbols turning at 5 Hz, it learns 5.000 Hz, and each corrected symbol lands on its decision. It also calls a sine and cosine for every symbol (&lt;code&gt;std::polar&lt;/code&gt;), the same kind of call the float section is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk the symbols back out
&lt;/h2&gt;

&lt;p&gt;Part 2 walked the message "Hi" into the transmitter and ended with the body randomizer's first values: 0 2 4 3 3 6. The first symbols of the burst's body carry exactly those, so here they are coming back out of the front end. Step 9, &lt;code&gt;walk()&lt;/code&gt;, prints them from the real code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;body  on-time I, Q       angle  tribit  sent  randomizer  tribit - randomizer  coded bit
   0   +2.369  +0.001     0.0       0     0           0                    0          0
   1   +0.014  +2.403    89.7       2     2           2                    0          0
   2   -2.409  -0.009   180.2       4     4           4                    0          0
   3   -1.731  +1.690   135.7       3     3           3                    0          0
   4   -1.661  +1.682   134.6       3     3           3                    0          0
   5   -0.016  -2.391   269.6       6     6           6                    0          0
 ...
  80   +1.687  -1.685   315.0       7     7           3                    4          1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each on-time sample sits within a degree of one of the eight phases, at the same magnitude, about 2.4: the loop is on the symbol peaks. The nearest phase gives the tribit, and every one matches what the transmitter sent. Subtract the randomizer, the same sequence Part 2 added, and what is left is the BPSK data: tribit 0 for a coded 0, tribit 4 for a coded 1. The first six body symbols happen to carry coded zeros, so on the air they are the randomizer itself. The first coded 1 arrives at body symbol 80: it comes in as tribit 7, and taking away the randomizer's 3 leaves 4.&lt;/p&gt;

&lt;p&gt;These coded bits are still in the interleaver's order, so they are not yet the bits of "H". Undoing the interleaver and the code is the work of later articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test bench
&lt;/h2&gt;

&lt;p&gt;This article is where the repository starts testing: at the receiver's first stage, so that each stage after it arrives with its own tests.&lt;/p&gt;

&lt;p&gt;The framework is Catch2. It is an external test tool, kept as a git submodule in &lt;code&gt;external/catch2&lt;/code&gt; and fixed to one release by its commit, and it builds only for the tests, never into the modem. Like the modem, it builds without exceptions: a failed &lt;code&gt;REQUIRE&lt;/code&gt; ends that test's process instead of throwing. CMake registers every test case as its own CTest test, so &lt;code&gt;ctest&lt;/code&gt; runs them all, and VS Code's Testing panel lists each one to run or debug on its own. Clone the repository with &lt;code&gt;--recurse-submodules&lt;/code&gt;, or run &lt;code&gt;git submodule update --init&lt;/code&gt; once.&lt;/p&gt;

&lt;p&gt;A test is built from three pieces in &lt;code&gt;tests/support/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A signal from the real transmitter&lt;/strong&gt; (&lt;code&gt;test_waveform&lt;/code&gt;). The Part 2 transmitter is the reference every receive stage is measured against, so it makes the test signals: a mode, a payload (given, or random from a seed so every run repeats) and any number of bursts, with what was sent kept beside the audio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A channel&lt;/strong&gt; (&lt;code&gt;test_channel&lt;/code&gt;): white noise, a CW tone and two-path Watterson fading, all from fixed seeds. Signal-to-noise is set the way the standard measures it, signal power over the noise power in a 3 kHz band. White noise spreads evenly from 0 to 24 kHz, so a 3 kHz band holds one eighth of its power. The fading model is an engineering model, not a qualified simulator, so a result through it is engineering evidence, not a conformance result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scorer&lt;/strong&gt; (&lt;code&gt;front_end_harness&lt;/code&gt;). It runs audio through a front end in any block sizes, lines the symbols up with what was sent, finds where the loop locked, and counts the decisions that match after that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The walkthrough steps are tests too: &lt;code&gt;front_end_tests&lt;/code&gt; calls the functions &lt;code&gt;front_end_demo&lt;/code&gt; calls and checks what they return. Around them are tests of whole bursts: "Hi" from start to finish, a train of random messages with gaps between them, white noise at 10 dB signal-to-noise, and a CW tone on the carrier 10 dB below the signal at 20 dB signal-to-noise. Two more run only when asked for. One is the thirty-minute run from "Keeping time for hours" (&lt;code&gt;ctest -L long&lt;/code&gt;). The other only reports: through Watterson fading (2 ms, 1 Hz, 20 dB signal-to-noise) the front end alone gets most decisions wrong, because removing the echoes is the equalizer's job (&lt;code&gt;ctest -L report&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  What the short preamble showed
&lt;/h3&gt;

&lt;p&gt;When I added a test for the short interleaver, it showed that a comment in the front end was wrong. The comment said the loop pulls in from any starting phase during the preamble. The 600S preamble is 1440 symbols, 0.6 seconds, and the test slides the "Hi" burst through every sample of one symbol period, so the loop starts at every phase it can meet. From 19 of the 20 starting phases it locks inside the preamble, by symbol 1000 at the latest, and every decision after that is right. Started exactly half a symbol off the symbol peaks, it locks only by symbol 2200, after the body has begun, on a clean signal and with white noise from 40 down to 10 dB signal-to-noise alike.&lt;/p&gt;

&lt;p&gt;That start is the early-late detector's unstable zero. The early and late samples are equally strong there, the error is close to zero, and the loop barely moves until the data pushes it off; the textbook name is a hang-up. Gardner's detector has the same point, and so does every timing detector, because its error repeats every symbol and has to cross zero twice. Started there, Gardner locked by symbol 1900: sooner, and still too late. The limit is how fast the loop pulls in: nine samples off the peak, either detector takes 1000 symbols to lock.&lt;/p&gt;

&lt;p&gt;The fix belongs with the receiver's conformance testing, later in the series, so for now the limit is documented, and I corrected the comment. The half-symbol start has its own test, tagged &lt;code&gt;[!shouldfail]&lt;/code&gt;. Catch2 counts its failure as expected, so the suite stays green; when the limit is fixed, that test's pass is reported as a failure, and the tag comes off. Until then the limit sits in the test list, where anyone who runs the tests sees it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 2, retrofitted
&lt;/h3&gt;

&lt;p&gt;Part 2's transmitter had &lt;code&gt;tx_demo&lt;/code&gt; and two golden tests, which check every transmitted symbol against a saved snapshot and the first samples of the waveform. It now has unit tests as well, in &lt;code&gt;tests/transmit_chain_tests.cpp&lt;/code&gt;, following that article's sections: the plan, the FEC, each rate's block plan, the interleaver, the Gray map, the randomizer, the carrier and the walk. Every number Part 2 quotes came out of them unchanged.&lt;/p&gt;

&lt;p&gt;One sentence needed correcting. Part 2 said each input bit "influences seven coded pairs, fourteen coded bits". The bit stays in the encoder for seven pairs, but each generator, 133 and 171 octal, has five taps, so ten of those fourteen coded bits depend on it; in the fifth pair, neither coded bit does. Part 2 now says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go read the code, and the standard
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The mix doubles each product. Why? (A real cosine is the sum of two phasors.)&lt;/li&gt;
&lt;li&gt;The ring buffer holds 1024 samples. Find the &lt;code&gt;static_assert&lt;/code&gt; beside it and work out how many the code actually needs, and why.&lt;/li&gt;
&lt;li&gt;Feed the matched filter a single impulse. What comes out, and why?&lt;/li&gt;
&lt;li&gt;The timing detector multiplies by the conjugate of the on-time sample. What would happen without the conjugate while the carrier phase rotates?&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;float&lt;/code&gt;, add 20 to 67,108,864. What do you get, and what does that do to a timing loop 23 minutes into a recording?&lt;/li&gt;
&lt;li&gt;The mixer's carrier index wraps at 80 instead of taking the sample count mod 80. Why not just use &lt;code&gt;%&lt;/code&gt; on the 64-bit count? (Look up what a 64-bit division costs on a Cortex-M33.)&lt;/li&gt;
&lt;li&gt;Work out the filter's cost per symbol if it ran at every sample, against running it only where the loop reads.&lt;/li&gt;
&lt;li&gt;Step 5, &lt;code&gt;timing_error()&lt;/code&gt;, reads the early-late detector on the receive pulse. Write Gardner's detector next to it and run both on the same pulse. Where do they agree, and how many interpolated samples does each need per symbol? Then put it in the loop in place of the early-late error and run the 600S tests. Does Gardner escape the half-symbol start? Why does every timing detector have a point like that?&lt;/li&gt;
&lt;li&gt;Step 4, &lt;code&gt;pulse_neighbors()&lt;/code&gt;, prints the leftover at the neighboring symbols for a receive rolloff of 0.25 and of 0.35. Why is the 0.25 leftover smaller, and what would you have to check before switching a qualified receiver to it?&lt;/li&gt;
&lt;li&gt;MIL-STD-188-110B holds the 1800 Hz carrier to within 1 Hz (paragraph 5.3.2.3.9) and every signaling rate to within 0.01 percent of nominal (paragraph 4.2.1). Put two modems at opposite ends of those limits: how far apart can their carriers be, and their symbol clocks, in parts per million? Compare that with the carrier tracker's limit of 0.1 pi radians per symbol, and with the timing loop's standing offset at 100 ppm. Then find what else in a real link moves the frequency further than the modems do.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;front_end_tests&lt;/code&gt; and find the &lt;code&gt;[!shouldfail]&lt;/code&gt; case. What does Catch2 print for it, and why does &lt;code&gt;ctest&lt;/code&gt; still pass? What has to happen before the tag can come off?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Next week: acquisition. This week's loop tracks a symbol instant it has been handed; acquisition finds a burst in noise, reads the mode from its countdown, and tells the receiver where the body starts.&lt;/p&gt;

&lt;p&gt;There is also one thing this week's loop cannot do. Two sample clocks never agree exactly, so part of the timing error is steady. At a 100 ppm clock offset the loop sits 0.74 samples off the eye, and at 1000 ppm it loses lock altogether (20 dB signal-to-noise). A proportional loop can only make a steady correction from a steady error, so it sits off the eye by the drift divided by its gain, and at 1000 ppm the offset it would need is past the peak of the detector's output. The fix is a second loop with an integrator, the same structure as the carrier tracker's, and next week I measure what it fixes and what it costs.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>dsp</category>
      <category>radio</category>
    </item>
    <item>
      <title>The transmit chain: the easy half, done right</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:49:40 +0000</pubDate>
      <link>https://dev.to/kg7hf/the-transmit-chain-the-easy-half-done-right-2540</link>
      <guid>https://dev.to/kg7hf/the-transmit-chain-the-easy-half-done-right-2540</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of "Building a MIL-STD-188-110 HF Modem in Modern C++." Start with the &lt;a href="https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0"&gt;overview&lt;/a&gt;, or Part 1, &lt;a href="https://dev.to/kg7hf/the-rules-i-build-by-1dj4"&gt;the rules I build by&lt;/a&gt;. The code for this article is on GitHub: &lt;a href="https://github.com/kg7hf/modem-core/tree/part-2" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At the end of Part 1 I called the transmitter the easy half. Encoding is deterministic, there is no channel to undo, and the standard spells out every step. I still hold it to the same discipline as the receiver.&lt;/p&gt;

&lt;p&gt;A transmitter error can pass every test you run. Get one bit order backwards, seed a scrambler one tap wrong, or number the interleaver rows the way that felt natural instead of the way the standard wrote them, and the waveform still looks fine on a scope and still decodes perfectly... in your own receiver, which makes the same mistake. Only another vendor's modem shows the error, and by then it is on the air.&lt;/p&gt;

&lt;p&gt;The best-known case of this kind of error is NASA's Mars Climate Orbiter. The interface specification called for thruster impulse in newton-seconds, but the contractor's ground software wrote it in pound-force seconds, and NASA's navigation software read those numbers as newton-seconds. Each program was consistent with itself; the mismatch showed only where the two met, and in 1999 the spacecraft was lost as it arrived at Mars. Part 1's unit types catch that kind of mismatch inside one program. Between two programs built by different teams, the specification is the only shared reference, so on the transmit side I follow it exactly, not my instincts.&lt;/p&gt;

&lt;p&gt;The transmitter is also the reference for everything that follows. The whole receiver, all nine stages, is tested against output from this side; the golden vectors that gate every build are transmit output, decoded and compared. A subtle transmitter error would carry into every receiver result measured against it.&lt;/p&gt;

&lt;p&gt;Here's a story from my childhood. When I was very young, my dad had a professional furniture repair shop. One day I was at his shop causing kid trouble, and he had had enough, so he wanted me to cut some wire to a specific length. He cut the first one, then showed me how to measure the next one with it; I was likely so young I couldn't even read a measuring tool. So piece by piece I cut, measured with the last one I cut, cut again, measured with the last one I cut... The astute reader can see where this is going. After some time, the wires I was cutting were getting longer and longer each time; little by little they were drifting, because of cumulative measurement errors, until I had pieces that were extraordinarily long.&lt;/p&gt;

&lt;p&gt;Each piece was measured against the one before it, so each small error carried into the next and added up. That is why the transmitter has golden tests: every build is checked against the same saved snapshot of its output, so errors cannot add up from one build to the next.&lt;/p&gt;

&lt;p&gt;The chain takes a few user bytes to an 1800 Hz waveform. It is linear, each stage feeding the next with no feedback, which is the other reason it is the easy half.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foucfwuz1ermyc6ljd0gu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foucfwuz1ermyc6ljd0gu.png" alt="The transmit chain, top to bottom: frame and plan, FEC encode, repetition, interleaver, constellation map, probes and preamble, scrambler, pulse shape and carrier"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The waveform is a family of modes, from 75 bps to 4800 bps. They share this chain and differ mainly in how much redundancy they add. I follow one mode the whole way down: &lt;strong&gt;600 bps with the long interleaver, "600L."&lt;/strong&gt; It is the mode I use most: slow enough to survive a rough NVIS path, fast enough for real traffic, and coded and interleaved enough to exercise every stage of the chain. Where another rate does something different, the 75 bps Walsh mode above all, I say so. I take it a stage at a time, then walk a two-character message through the whole chain in 600L.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan the whole burst before you move a bit
&lt;/h2&gt;

&lt;p&gt;Part 1's first rule was no heap on the hot path: the signal path allocates nothing while it runs. So the transmitter does all of its sizing up front. Before a single bit is encoded, &lt;code&gt;body_transmission_plan&lt;/code&gt; sizes the entire transmission: the countdown preamble, the coded body blocks, the end-of-message marker, the coder flush, and the padding that squares off the final interleaver matrix. It returns a plan, not a buffer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;BodyTransmissionPlan&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BodyBlockPlan&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;payload_bits&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;framed_bits&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;body_blocks&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;preamble_symbols&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;transmitted_symbols&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;    &lt;span class="n"&gt;audio_samples&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BodyTransmissionPlan&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;body_transmission_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BodyMode&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;payload_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The render step then takes that plan, the payload, and a &lt;code&gt;BodyTransmissionScratch&lt;/code&gt; of caller-owned work buffers, and writes the audio without touching the heap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;
&lt;span class="n"&gt;generate_body_transmission_audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BodyTransmissionPlan&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;BodyTransmissionScratch&lt;/span&gt; &lt;span class="n"&gt;scratch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;MutableSampleSpan&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every buffer the pipeline needs (the framed bits, the coded bits, the interleaver matrix, the interleaved bits, the transmitted tribits) is a span the caller prepared, sized from the plan. On a microcontroller, that is what lets you prove the transmit path never runs out of memory. Errors are Part 1's error values: a request the plan cannot size comes back as a failed &lt;code&gt;Result&lt;/code&gt;, and buffers that do not match the plan come back from the render as a &lt;code&gt;Status&lt;/code&gt;, both before any audio is written.&lt;/p&gt;

&lt;h2&gt;
  
  
  The FEC: spend bits to buy survival
&lt;/h2&gt;

&lt;p&gt;After framing comes forward error correction: the modem adds redundant bits so the receiver can correct errors. The serial-tone body uses a &lt;strong&gt;K=7, rate-1/2 convolutional code&lt;/strong&gt;, the classic NASA-heritage code with generator polynomials &lt;strong&gt;133 and 171 in octal&lt;/strong&gt;. Rate 1/2 means every input bit produces two coded bits; K=7 means the encoder remembers six previous bits, so each output depends on the last seven inputs. That overlap lets a decoder correct errors that a code without memory could not.&lt;/p&gt;

&lt;p&gt;In code the encoder is a tiny state machine. You push one bit and get back the pair of coded bits it produced, T1 then T2, which is the order the standard serializes them and the order the receiver has to expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;EncodedPair&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConvolutionalEncoderK7&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="nl"&gt;public:&lt;/span&gt;
    &lt;span class="k"&gt;explicit&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;ConvolutionalEncoderK7&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;initial_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;EncodedPair&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;bit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// generators 133/171 octal&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The T1-before-T2 order has to match on both ends, so it is documented in the encoder's source, and the golden test, which checks every transmitted symbol against a saved snapshot, fails if the order ever flips. The lower rates add more redundancy on top of the code, which is the next stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repetition and Walsh: more redundancy, lower rates
&lt;/h2&gt;

&lt;p&gt;The rate-1/2 code alone carries 600, 1200, or 2400 bits per second of user data over the 2400 symbol-per-second channel, depending on how many bits each symbol carries. The lower rates add repetition. At 300 and 150 bps the coded bits are repeated, so the receiver can combine several noisy copies into one decision; the encoder provides this as &lt;code&gt;encode_repeated_pairs&lt;/code&gt;. At 75 bps the modem switches to &lt;strong&gt;Walsh orthogonal spreading&lt;/strong&gt;, giving up almost all of its throughput for a signal that survives conditions the coded rates cannot. At the top, 4800 bps is uncoded: no convolutional code at all, so it has no error protection and depends entirely on the equalizer. (The tail-biting punctured variant you will also find in the encoder belongs to the 110B high-rate waveforms, a different family.) So the mode selects one of four options: the plain code, repetition, Walsh spreading, or no code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interleaving: scatter the damage
&lt;/h2&gt;

&lt;p&gt;Fades on HF are bursty. A 200 ms null wipes out a long run of consecutive bits, more than the code can correct. So between coding and modulation a block interleaver scatters the bits in time: a &lt;strong&gt;0.6 second "short"&lt;/strong&gt; matrix or a &lt;strong&gt;4.8 second "long"&lt;/strong&gt; one. The coded bits are written into the matrix one way and read out another, so bits that were neighbors in the code end up spread across the interleaver span on the air. A fade then damages a few bits in many places instead of a long run in one place, and the code corrects scattered errors well. The longer the span, the longer the fade it can break up. The cost is latency: 4.8 seconds for the long interleaver.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bits to symbols: the constellation and a Gray map
&lt;/h2&gt;

&lt;p&gt;Next the coded bits become points on the constellation. Every mode sends &lt;strong&gt;2400 symbols per second on the same 1800 Hz carrier&lt;/strong&gt;; the data rate changes how many bits each symbol carries. The fast modes use the full &lt;strong&gt;8-PSK&lt;/strong&gt; circle, three bits per symbol; 1200 bps uses QPSK, two bits; and &lt;strong&gt;600L uses BPSK, one bit per symbol&lt;/strong&gt;, two points on opposite sides of the circle. Fewer points sit farther apart, so it takes more noise to turn one into another.&lt;/p&gt;

&lt;p&gt;The mapping from bits to phase is Gray-coded on purpose: adjacent constellation points differ by exactly one bit, so when noise nudges a symbol into its neighbor, the receiver's most likely error, only one bit flips instead of several. All of it comes from one shared function, &lt;code&gt;modified_gray_decode&lt;/code&gt;, read the same way by the transmitter and both demappers, with a small table for each width:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MIL-STD-188-110B body-waveform modified-Gray mapping.&lt;/span&gt;
&lt;span class="c1"&gt;// width 3 (8-PSK): the full table.  width 1 (BPSK, 600L): bit 0 -&amp;gt; 0, bit 1 -&amp;gt; 4.&lt;/span&gt;
&lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5U&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So in 600L a coded 0 becomes tribit 0, the point at 0 degrees, and a coded 1 becomes tribit 4, the point at 180 degrees. A mistake in this mapping goes out on the air, so the table lives in one place instead of being retyped wherever a symbol is formed, and a &lt;code&gt;static_assert&lt;/code&gt; locks its derived cases.&lt;/p&gt;

&lt;p&gt;(The 75 bps mode is the exception: instead of mapping coded bits to single symbols, it sends each pair of coded bits as a 32-symbol Walsh sequence. It rejoins the chain at the scrambler.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Probes and the countdown preamble
&lt;/h2&gt;

&lt;p&gt;A receiver cannot equalize a channel it cannot measure, so the transmitter inserts known symbols into the stream. These &lt;strong&gt;probe&lt;/strong&gt; symbols are values the receiver can predict exactly; it uses them to keep estimating the channel as it changes. The equalizer, in a later article, depends on them.&lt;/p&gt;

&lt;p&gt;And the whole burst opens with the &lt;strong&gt;countdown preamble&lt;/strong&gt;. It does two jobs. It announces the mode, so a receiver that had no idea what was coming learns the rate, the interleaver length, everything it needs to configure itself. And it counts down to the data, so a receiver that wakes up in the middle of the preamble, which on a low-power part listening for activity is the normal case, knows exactly how many symbols remain until the payload begins. That countdown is what lets the Cortex-M33 from the overview sleep through dead air and still catch the start of a message. Finding the preamble in noise is the receiver's job, in Part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scrambling: whiten it, and exactly
&lt;/h2&gt;

&lt;p&gt;The last step before the carrier is the scrambler, and it has to match the standard exactly. Long runs of identical symbols are bad for a receiver's timing and carrier recovery and bad for the transmitter's spectrum, so the modem adds a known pseudo-random sequence to whiten them. By now the data is tribits on an 8-PSK ring, so the scramble is modular addition on the ring, not an exclusive-or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// (tribit + randomizer) mod 8, on the 8-PSK ring&lt;/span&gt;
&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt; &lt;span class="nf"&gt;tribit_add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="n"&gt;tribit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="n"&gt;randomizer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;low_bits&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tribit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;randomizer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The randomizer itself is a linear-feedback shift register (&lt;code&gt;BodyDataRandomizer&lt;/code&gt;), and it whitens every body symbol, data and probes alike; the preamble gets its own fixed sequence (&lt;code&gt;SyncRandomizer&lt;/code&gt;), the one the receiver correlates against. Every part of the system that has to agree on these sequences, the transmit mapper, the probe generator, the receiver's reference, adds the randomizer the same way through this one function. That is DRY, from Part 1's list, applied to the standard: there is exactly one definition of adding the randomizer, so the transmitter and the receiver cannot disagree about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning symbols into 1800 Hz
&lt;/h2&gt;

&lt;p&gt;Pulse shaping and modulation are the only real signal processing on the transmit side. Each symbol, whichever part of the constellation the rate uses, is a phase on a single &lt;strong&gt;1800 Hz carrier&lt;/strong&gt;, at &lt;strong&gt;2400 symbols per second&lt;/strong&gt;. To keep it from splattering into the neighboring spectrum, each symbol is shaped with a &lt;strong&gt;square-root raised-cosine&lt;/strong&gt; pulse; the receiver filters with the same family of pulse. The carrier itself is a phase that advances a fixed step per sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Carrier phase advance per sample: (two_pi * f) / fs, in Real, in the shipped order.&lt;/span&gt;
&lt;span class="k"&gt;template&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HertzSource&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;Real&lt;/span&gt; &lt;span class="nf"&gt;radians_per_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SampleRate&lt;/span&gt; &lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;two_pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Real&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hertz&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hertz_real&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The carrier-step check: the exact float the shipped code produced for the body carrier.&lt;/span&gt;
&lt;span class="k"&gt;static_assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;bit_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uint32_t&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;radians_per_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Frequency&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;1800.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Hz&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;SampleRate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;48000U&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mh"&gt;0x3E714639U&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several of Part 1's rules show up in that one return statement. The carrier and the sample rate are unit types, not bare numbers, so passing a sample rate where the carrier belongs does not compile. The arithmetic stays in the single &lt;code&gt;Real&lt;/code&gt; type, so nothing promotes to a software-emulated double on the M33. The operation order is fixed, &lt;code&gt;two_pi * f&lt;/code&gt; then divide, so the value is bit-for-bit the same on every build, and the &lt;code&gt;static_assert&lt;/code&gt; locks that float to its bit pattern at compile time. Every sample the transmitter emits uses this step, so a one-bit change here changes the whole waveform. The modulator renders straight into the caller's audio span without allocating, so the same code can write a WAV file on a PC or feed a DAC on the microcontroller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk a message through it
&lt;/h2&gt;

&lt;p&gt;Here is the two-character message "Hi" pushed through the chain in 600L. Every value below is what the code actually produces, not an illustration; I ran the real encoder and modulator stages and copied the numbers out, and &lt;code&gt;tx_demo&lt;/code&gt; in the repository prints the bit-level ones.&lt;/p&gt;

&lt;p&gt;The transmitter takes user octets &lt;strong&gt;least-significant bit first&lt;/strong&gt;. The byte for 'H' is 0x48, so it enters the encoder as the bit sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'H' = 0x48 -&amp;gt; 0 0 0 1 0 0 1 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those bits feed the K=7 encoder one at a time, and each one comes back as a coded pair, t1 then t2. Starting from the all-zero state, the eight bits of 'H' produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input bit:  0    0    0    1    0    0    1    0
coded pair: 00   00   00   11   01   11   00   01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the fourth column. The input bit there is the first 1 in 'H', and both coded bits change. The encoder's memory then affects the pairs after it, even where the inputs are 0 again. Each input bit influences seven coded pairs, fourteen coded bits, which is what lets the receiver recover it after the channel corrupts some of them.&lt;/p&gt;

&lt;p&gt;Because 600L is BPSK, each coded bit becomes one symbol on its own; there is no gathering into threes. A coded 0 maps to tribit 0 and a coded 1 maps to tribit 4, the two points at 0 and 180 degrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coded bit 0 -&amp;gt; tribit 0  (0 degrees)
coded bit 1 -&amp;gt; tribit 4  (180 degrees)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interleaving sits between the code and this mapping, and it is the one stage a single-message trace cannot really show on paper: it scatters the coded bits across the whole 4.8 s long-interleaver span, so two bits that were neighbors above end up hundreds of milliseconds apart on the air, and a fade that would have wiped out a run of them instead leaves isolated errors scattered across the codeword.&lt;/p&gt;

&lt;p&gt;Last, the scrambler adds its known sequence on the ring before the symbol reaches the carrier. The body randomizer's first tribits are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0  2  4  3  3  6 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a coded 1: it maps to tribit 4, and with a randomizer value of 2 it becomes &lt;code&gt;(4 + 2) mod 8 = 6&lt;/code&gt;. That is the tribit that goes out on the air, at &lt;code&gt;6 x 45 = 270&lt;/code&gt; degrees. BPSK only produces tribit 0 or 4, two phases, but after the randomizer is added the transmitted symbols land on all eight, so the spectrum looks full and random even though the data is two-level. The receiver runs the same randomizer, subtracts the same 2, recovers tribit 4, and reads it as a 1. The sequence is known to both ends; it only looks random on the air.&lt;/p&gt;

&lt;p&gt;The transmitted tribit is a phase. The modulator turns tribit 6 into a point on the 8-PSK circle, &lt;code&gt;psk8_symbol(6)&lt;/code&gt;, the unit vector at &lt;code&gt;6 x 45 = 270&lt;/code&gt; degrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tribit 6 -&amp;gt; I = cos(270) = 0.000,  Q = sin(270) = -1.000   (magnitude 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the point modulates the carrier. Each output sample advances the carrier phase by the fixed step the &lt;code&gt;static_assert&lt;/code&gt; locks, &lt;code&gt;2*pi*1800/48000 = 0.2356194&lt;/code&gt; radians, which is 13.5 degrees per sample; at 48 kHz one full cycle of the 1800 Hz tone is about 26.7 samples, and each symbol occupies 20 of them. The audio sample is the real part of the symbol rotated by that phase, scaled for headroom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;Re&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;carrier_phase&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see it, hold tribit 6 steady and the modulator emits a clean 1800 Hz sinusoid sitting at that 270-degree offset. These are eight consecutive samples the modem actually produces, climbing to a crest and turning over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n=241  n=242  n=243  n=244  n=245  n=246  n=247  n=248
0.126  0.244  0.349  0.435  0.497  0.531  0.536  0.511
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the 1800 Hz carrier, one sample every 13.5 degrees, with its amplitude kept under the &lt;code&gt;0.98&lt;/code&gt; no-clip limit the modulator derives from its own pulse. In live traffic the phase changes every 20 samples as the tribits change (for 600L the data only ever picks tribit 0 or tribit 4, and the randomizer spreads those picks across all eight phases), and the square-root raised-cosine pulse smooths each change so the burst stays inside its band.&lt;/p&gt;

&lt;p&gt;Every value here is deterministic, reproducible, and defined in exactly one place, so a receiver that does each step in reverse, mine or an independent one, gets back the bytes "Hi".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the easy half gets the same rigor
&lt;/h2&gt;

&lt;p&gt;The transmit chain is short: plan, encode, repeat, interleave, map or spread, probe, scramble, shape. None of it is hard the way undoing a fading channel is hard. But at every stage a detail the standard fixes can be subtly wrong in a way that testing against your own receiver never reveals, because your receiver shares the mistake. So I treat the transmitter as ground truth. Its output is the reference the whole receiver is measured against, and it is what an independent implementation has to decode. It has to be exactly right for anyone else to talk to you, which means building it once, carefully, from the specification, and keeping it from drifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go read the code, and the standard
&lt;/h2&gt;

&lt;p&gt;The core skill in this work is reading a specification and confirming that the code does what it says. The code is on GitHub at &lt;a href="https://github.com/kg7hf/modem-core/tree/part-2" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;, and its &lt;a href="https://github.com/kg7hf/modem-core/blob/part-2/docs/start-here.md" rel="noopener noreferrer"&gt;start-here page&lt;/a&gt; maps it stage by stage. MIL-STD-188-110 is public (pull it from EverySpec or the DoD's ASSIST site). It is written in "shall" statements, with the exact numbers in its tables and figures. Several of the questions below have you open the standard and the source side by side, find a requirement, and check that the code meets it. That is most of the daily work in mission-critical engineering.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What carrier frequency and symbol rate does this waveform use, and where are they set? One search; they are named constants, not magic numbers.&lt;/li&gt;
&lt;li&gt;The standard gives the K=7 code's generator polynomials in &lt;strong&gt;octal&lt;/strong&gt;: 133 and 171. The code stores them as &lt;code&gt;0x6D&lt;/code&gt; and &lt;code&gt;0x4F&lt;/code&gt;. Convert 133 and 171 to hex yourself; you will get &lt;code&gt;0x5B&lt;/code&gt; and &lt;code&gt;0x79&lt;/code&gt;, which do &lt;em&gt;not&lt;/em&gt; match. Work out what the code did, and why. (Which end of the shift register feeds the taps?)&lt;/li&gt;
&lt;li&gt;The end-of-message marker is a specific 32-bit word. Find it in the code (a single hex literal), then find where the standard specifies it, and check it bit for bit. Why would a wrong EOM word pass every one of your own tests and only surface when another vendor's modem reads your signal?&lt;/li&gt;
&lt;li&gt;The body data randomizer is a 12-bit shift register. MIL-STD-188-110B Figure 6 draws it, including the value it is loaded with at the start of every burst; the code even cites the page. Find the initial load in the code and confirm it against the figure. (Write &lt;code&gt;0xBAD&lt;/code&gt; in binary and compare it with the figure.)&lt;/li&gt;
&lt;li&gt;The 8-PSK modified-Gray mapping is a table in the standard (MIL-STD-188-110B section 5.3.2.3.6, Tables XII and XIII). The code carries it as an eight-entry array. Rebuild the table from the standard, check every entry against the array, then confirm the one-bit-between-neighbors property the Gray code is there to provide.&lt;/li&gt;
&lt;li&gt;600L uses BPSK, one bit per symbol; 2400 bps uses full 8-PSK, three. Find the single place that decides how many bits each symbol carries, and tie 600, 1200, and 2400 bps each back to the modulation the standard calls for at that rate.&lt;/li&gt;
&lt;li&gt;The interleaver is a rows-by-columns matrix: you load it one way and read it another. Find the matrix geometry in the code, match it against the dimensions the standard gives for the 4.8 second interleaver, and work out how far apart on the air two bits that were neighbors going in end up.&lt;/li&gt;
&lt;li&gt;A receiver cannot equalize a channel it cannot measure. Find where the transmitter builds the countdown preamble and inserts probe symbols into the data. What is the countdown counting down to, and why does a battery-powered receiver need it?&lt;/li&gt;
&lt;li&gt;The article claims the signal path allocates nothing. Do not take my word for it: start at &lt;code&gt;generate_body_transmission_audio&lt;/code&gt; and prove it. Show every buffer came from the caller, find the one place all the sizing is decided, and find the &lt;code&gt;Result&lt;/code&gt; that rejects an impossible request before any work starts.&lt;/li&gt;
&lt;li&gt;The transmitter is the reference the whole receiver is measured against, so its output has to be reproducible. The carrier step is &lt;code&gt;(two_pi * f) / fs&lt;/code&gt;. Predict, in float, whether rewriting it as &lt;code&gt;two_pi * (f / fs)&lt;/code&gt; changes a single output sample, &lt;em&gt;before&lt;/em&gt; you test it. Then find one more place where the order of a floating-point operation is fixed on purpose, and say what a re-order would cost.&lt;/li&gt;
&lt;li&gt;Build &lt;code&gt;tx_demo&lt;/code&gt; in Debug and in Release, with GCC and with Clang, and compare the step 7 hash and the WAV files. The symbols match on every build. Do the samples? If they do not, find out why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For those excited to see what the receiver looks like, the headers in &lt;a href="https://github.com/kg7hf/modem-core/tree/part-2" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; already declare a body decoder, a Viterbi decoder, a soft demapper, and a carrier tracker that it does not yet implement. Read their design notes and ask: from the transmitter alone, what is the first thing the receiver has to undo?&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Next week I move to the receiver and start undoing the channel: the audio front end, matched filtering and symbol timing recovery, where the receiver first has to find where each symbol is. I'll also explain why this week's audio can differ in the last bit between two builds of the same source, even though every symbol matches.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>hamradio</category>
      <category>amateurradio</category>
    </item>
    <item>
      <title>The rules I build by</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sun, 27 Sep 2026 00:14:19 +0000</pubDate>
      <link>https://dev.to/kg7hf/the-rules-i-build-by-1dj4</link>
      <guid>https://dev.to/kg7hf/the-rules-i-build-by-1dj4</guid>
      <description>&lt;p&gt;&lt;em&gt;New here? Start with the &lt;a href="https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0"&gt;overview&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before the rules, a debt. These are not mine alone; they are the labor of a whole career, and every one of them was shaped by the engineers I have had the honor to work beside. Most of what I hold to here was taught to me, one code review and one hard-won bug and one patient explanation at a time. That is really the method, more than any single rule below: let your thinking evolve with every project and every review, keep what survives contact with the work, and let the rest go. They have changed as the standards and the tools changed, and they will keep changing. I set them down not as a decree but as what I have learned so far; if they are any good the credit is shared, and if they are still improving, that is rather the point. Special thanks to Brian, Jim, Erik, Patty, Dan, Glenn, Ken, and the many others I have worked beside over the years, who formulated and crafted these ideas with me.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the overview I said the whole modem has to run on a microcontroller, not just a workstation, and that one constraint shaped every line of it. This installment is that constraint, spelled out: the rules I hold myself to, and why I think they make the code both safer and faster than the C it is so often assumed to be slower than.&lt;/p&gt;

&lt;p&gt;These aren't modem rules, or even embedded rules; I hold every project to them, and that consistency is the point. Apply the same discipline everywhere and two things compound. You get better at writing it right the first time, so the gap between "it works" and "it's right" keeps shrinking. And the reach-back gets cheap: that genuinely useful function you wrote eight months ago drops into the new project with far less rework, because it already speaks the same types, returns errors the same way, and owns nothing it shouldn't. Discipline you practice everywhere is discipline that pays you back.&lt;/p&gt;

&lt;p&gt;Here is the shape of it before the detail, so you know where we are headed. One distinction sits under everything: &lt;strong&gt;style&lt;/strong&gt;, which only ever costs you a confused maintainer, versus &lt;strong&gt;functional&lt;/strong&gt; discipline, which is what actually keeps the program from failing. The embedded target then forces exactly three carve-outs from the language, and everything that remains organizes into four habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory and mutation, locked down.&lt;/strong&gt; No heap on the hot path, a bounded and knowable stack, &lt;code&gt;const&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One type, top to bottom.&lt;/strong&gt; A single numeric scalar, almost no casts, and physical quantities that carry their units in their types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checked, not hoped.&lt;/strong&gt; Errors are values you cannot ignore, one way to fail everywhere, and as much proof as possible moved to compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less you have to trust.&lt;/strong&gt; No macros computing values, no shared mutable state in the core, the algorithms portable and the platform kept at the edges.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The claim underneath all of it, the one I want to earn over the rest of this piece, is that this is not the slow, careful C++ people brace for. Done this way it is both safer &lt;em&gt;and&lt;/em&gt; faster than the C it is so often assumed to trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded-first, or nothing
&lt;/h2&gt;

&lt;p&gt;Here's the constraint that shaped everything: this core must cross-compile to embedded microcontrollers, Cortex-M33 and M7-class parts, not just run on a workstation. If you've ever watched a single stray heap allocation blow a real-time audio deadline on a small ARM part, you already know why there is no &lt;code&gt;new&lt;/code&gt; anywhere in my signal path. That one mandate rules out a mountain of the bloat that quietly accretes in DSP code, and it turns "good style" from a preference into a spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules
&lt;/h2&gt;

&lt;p&gt;Before any specific rule, one distinction that matters more than any single one of them: there are two kinds of "coding standard," and they fail in completely different ways.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;style&lt;/strong&gt;, which is about human readability and nothing else: what you name a variable, a class, a function, a member; tabs or spaces; where a line wraps. A formatter like &lt;code&gt;astyle&lt;/code&gt; enforces most of it, and a short style guide covers the rest. Get style wrong and nothing breaks; you've just left visual noise for whoever maintains the code next, which in mission-critical work is far more often than you write it. It matters, but it matters to people, not to the compiler.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;functional&lt;/strong&gt;, and it's where the &lt;a href="https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md" rel="noopener noreferrer"&gt;C++ Core Guidelines&lt;/a&gt; earn their keep: disciplined casting, strict types, honest error handling, DRY, &lt;code&gt;const&lt;/code&gt; used liberally (const data, const pointers, or better still a reference), buffers that carry their length with their type (&lt;code&gt;std::array&lt;/code&gt;, not a bare pointer and a separate size you hope still matches), scopes kept small, every object initialized before it's used, one name declared per line and only once you have a value for it. Get one of these wrong and the program can actually fail: a silent narrowing, an uninitialized read, a dangling reference, a buffer that lost track of its length. I follow almost all of them.&lt;/p&gt;

&lt;p&gt;Almost, because the embedded target forces exactly three carve-outs, and all three are functional: &lt;strong&gt;no exceptions, no RTTI, and no dynamic memory allocation&lt;/strong&gt; on the signal path. Everything else in the guidelines stands. So the build runs under &lt;code&gt;-fno-exceptions -fno-rtti&lt;/code&gt;, warnings-as-errors, and in practice the functional rules look like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory and mutation, locked down.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No heap on the hot path.&lt;/strong&gt; Every buffer is a fixed &lt;code&gt;std::array&lt;/code&gt; sized by a compile-time constant, its type and its length traveling together. The receiver allocates &lt;em&gt;nothing&lt;/em&gt; while running; pools are prepared before the stream starts, and real-time callbacks never allocate, never block, never touch I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded, known stack; no unbounded recursion.&lt;/strong&gt; With the heap gone, the stack is the last place memory can surprise you, so recursion on the signal path is out and the depth stays bounded and knowable instead of a runtime unknown. I build with &lt;code&gt;-fstack-usage&lt;/code&gt;, and every function in the core reports a fixed, compile-time frame; there are no variable-length arrays and no &lt;code&gt;alloca&lt;/code&gt;, so nothing can grow the stack while the program runs. On a part with no MMU, a stack that can grow without limit is just a slower way to corrupt memory; I would rather know the worst case before the part ships than discover it in the field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;const&lt;/code&gt; by default, references over pointers.&lt;/strong&gt; Data is &lt;code&gt;const&lt;/code&gt; unless it has a reason to change, and where a raw pointer would do I reach for a reference instead. Fewer things can move, so fewer things can go wrong.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;maximum_taps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maximum_taps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mCoefficients&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;   &lt;span class="c1"&gt;// fixed size, zero-init, never allocates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One type, top to bottom.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One numeric type, chosen at build time.&lt;/strong&gt; The whole modem is parameterized on a single &lt;code&gt;Real&lt;/code&gt; scalar: &lt;code&gt;float&lt;/code&gt; by default, which already carries far more precision than the data ever has (the datasheet note below), with a seam already cut for a future fixed-point Q15/Q31 backed by the M33's CORDIC. Verification is by bit-exact golden vectors and an independent reference decoder, not by a higher-precision build; the point of one type is that nothing silently promotes or narrows between the layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One type all the way up and down, almost no casts.&lt;/strong&gt; Unify a call stack on one type and functions stop converting a size to call a helper that converts it back. Casts are where bugs hide (a silent narrowing, a signed/unsigned flip) and where the compiler stops being able to help you, and they are rarely free: an int-to-float conversion is a &lt;code&gt;VCVT&lt;/code&gt;, a stray promotion to &lt;code&gt;double&lt;/code&gt; is a software-emulated call on a part with no hardware double, and a few of those per sample, multiplied across a real-time block, is how a DSP loop quietly eats its cycle budget and misses its deadline. The only sanctioned cast is at the boundary of an API you don't own: the codec's &lt;code&gt;int16&lt;/code&gt; buffer, a published C/DMA interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strong types, not bare numbers.&lt;/strong&gt; A physical quantity carries its unit in its type: a &lt;code&gt;Frequency&lt;/code&gt;, an &lt;code&gt;Angle&lt;/code&gt;, a &lt;code&gt;Decibel&lt;/code&gt;, a &lt;code&gt;SampleRate&lt;/code&gt;, a &lt;code&gt;Baud&lt;/code&gt;, a &lt;code&gt;BitRate&lt;/code&gt;. You cannot build one or read it back without naming a unit, so a hertz cannot be mistaken for a kilohertz and a symbol rate cannot be passed where a bit rate belongs. The decibel types go a step further and know their own algebra: a gain adds to a power level, the difference of two power levels is a ratio, and adding two absolute power levels does not compile at all. It is the same instinct as the single &lt;code&gt;Real&lt;/code&gt; type, taken up a level; make the wrong thing impossible to say, and let the compiler enforce the physics. And it is free at runtime: the wrappers fold away, so on the microcontroller a &lt;code&gt;Frequency&lt;/code&gt; costs exactly what the bare number underneath it would.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;Real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// the one scalar the whole modem is built on&lt;/span&gt;
&lt;span class="n"&gt;Frequency&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Hz&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;         &lt;span class="c1"&gt;// the unit lives in the type&lt;/span&gt;
&lt;span class="n"&gt;Power&lt;/span&gt;     &lt;span class="n"&gt;eirp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transmit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;antenna&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dBW + dBi -&amp;gt; Power; but Power + Power will not compile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Checked, not hoped.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Errors are values, not surprises.&lt;/strong&gt; With exceptions off, a fallible operation returns a &lt;code&gt;[[nodiscard]] std::expected&amp;lt;T, Status&amp;gt;&lt;/code&gt;, C++23's standard "a value, or the reason there isn't one," and exactly the shape the Core Guidelines' no-exceptions guidance describes (E.25 through E.28): you must handle it; you can't forget it, and you can't be ambushed by a throw three layers down. I mark the error type itself &lt;code&gt;[[nodiscard]]&lt;/code&gt;, too, so even a bare status returned by value fails the build when it is dropped; forgetting to check an error is not a mistake you can make by accident, it is a mistake the compiler refuses to let you commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One wrinkle earns its own sentence, because it is exactly where the no-exceptions rule and &lt;code&gt;std::expected&lt;/code&gt; meet. With exceptions off, calling &lt;code&gt;.value()&lt;/code&gt; on an error does not throw; it aborts, with no diagnostic and no &lt;code&gt;Status&lt;/code&gt; left to inspect. So the fallible type is a thin &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; wrapped over &lt;code&gt;std::expected&amp;lt;T, Status&amp;gt;&lt;/code&gt;: the storage is the standard type, but &lt;code&gt;value()&lt;/code&gt; routes a missing value through an installable handler that can record the failure before it traps. It is one seam kept on purpose, for the one diagnostic the bare abort would have swallowed; everything else is just &lt;code&gt;std::expected&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In code the wrapper is small, and the whole design lives in one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A fallible T: std::expected&amp;lt;T, Status&amp;gt; underneath (standard, constexpr, trivially&lt;/span&gt;
&lt;span class="c1"&gt;// copyable when T is), wrapped for one reason. With exceptions off, std::expected's&lt;/span&gt;
&lt;span class="c1"&gt;// own value() on an error aborts, with no diagnostic and no Status to inspect.&lt;/span&gt;
&lt;span class="k"&gt;template&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typename&lt;/span&gt; &lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="nl"&gt;public:&lt;/span&gt;
    &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;noexcept&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="nf"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unexpected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;// `return Status{...}` just works&lt;/span&gt;

    &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="k"&gt;explicit&lt;/span&gt; &lt;span class="k"&gt;operator&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_value&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Safe on success too, unlike std::expected::error(): a good result reports an ok Status.&lt;/span&gt;
    &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The one seam: a missing value routes through an installable handler that can record the&lt;/span&gt;
    &lt;span class="c1"&gt;// failure before it traps, instead of the blind abort. (The const&amp;amp; and &amp;amp;&amp;amp; overloads mirror this.)&lt;/span&gt;
    &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;critical_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So where a signature above hands back &lt;code&gt;std::expected&amp;lt;T, Status&amp;gt;&lt;/code&gt;, the shipped type is this &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;: the same value-or-error contract, drop-in with the standard type, plus the one trap the bare abort would have swallowed. The implicit &lt;code&gt;Result(Status)&lt;/code&gt; constructor is what lets a function just &lt;code&gt;return Status{...}&lt;/code&gt; on failure and &lt;code&gt;return value;&lt;/code&gt; on success, with no ceremony at either end.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One way to fail, everywhere.&lt;/strong&gt; The single-type rule has an error-handling twin. If a function can fail it returns the one fallible type, a &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; (or a &lt;code&gt;Status&lt;/code&gt; when there is no value to hand back), and nothing else; never a bare &lt;code&gt;bool&lt;/code&gt; that secretly means "did it work," never a sentinel &lt;code&gt;-1&lt;/code&gt; or &lt;code&gt;SIZE_MAX&lt;/code&gt;, never a naked error code whose meaning the caller has to memorize. A &lt;code&gt;bool&lt;/code&gt; gets exactly one job, a question the caller asks (&lt;code&gt;is_locked()&lt;/code&gt;, &lt;code&gt;empty()&lt;/code&gt;), never an answer about whether the last call survived. One channel means nobody learns a new failure convention per function: the way you check the channel estimate is the way you check the demodulator is the way you check the file open, a failure three layers down propagates up in the same shape, and the test is always &lt;code&gt;if (!result)&lt;/code&gt; with the reason riding along. It is also a library's proper manners; the core originates failures and passes them up, it almost never inspects them, because deciding what an error means is the caller's job. That is why the error-code checks in this project cluster in the application that consumes the modem, not in the modem itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute at compile time, check at compile time.&lt;/strong&gt; Anything knowable before the program runs, a table, a size, a bit of geometry, is &lt;code&gt;constexpr&lt;/code&gt;; the invariants that must hold, a buffer sized for the worst case, a type that must stay trivially copyable, a rate that divides evenly, are &lt;code&gt;static_assert&lt;/code&gt;ed, so a wrong assumption fails the build instead of the mission. There are well over a hundred of these compile-time checks across the core, and every one of them costs nothing at runtime.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChannelEstimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;estimate_flat_channel&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;static_assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;4096U&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// a wrong assumption fails the build, not the mission&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Less you have to trust.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No macros for values or logic.&lt;/strong&gt; A constant is a &lt;code&gt;constexpr&lt;/code&gt;, a set of states is an &lt;code&gt;enum class&lt;/code&gt;, a small helper is an &lt;code&gt;inline&lt;/code&gt; function or a template. A &lt;code&gt;#define&lt;/code&gt; is text substitution with no type and no scope, and it bites where the debugger cannot follow it: the double evaluation, the silent narrowing, the name that collides with something three headers away. In the whole core the only macros are a platform export shim and a couple of build-time feature gates; nothing computes with them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared mutable state in the core.&lt;/strong&gt; The portable algorithms are single-threaded on purpose. On a small part a mutex is expensive and there is no reason to be multi-threaded, so the core owns no interrupts, no timers, no DMA, and nothing two contexts can touch at once. The real-time handoff lives at the platform edge, and it stays simple: on the desktop side the audio is a plain producer-consumer relationship, one writer and one reader, not a web of shared locks. Concurrency bugs live in that seam, so I keep the seam thin and out of the algorithms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable core, platform at the edges.&lt;/strong&gt; The algorithms know nothing of UI, OS, or drivers. The same source that runs a desktop diagnostic tool compiles into firmware.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;maximum_taps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// typed, scoped, and the debugger can see it&lt;/span&gt;
&lt;span class="c1"&gt;// not:  #define MAXIMUM_TAPS 32            // text substitution, no type, no scope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And none of it runs on the honor system. A formatter (&lt;code&gt;clang-format&lt;/code&gt; or &lt;code&gt;astyle&lt;/code&gt;) enforces the style half, so no one argues about names or indentation in review. &lt;code&gt;clang-tidy&lt;/code&gt;'s &lt;code&gt;cppcoreguidelines&lt;/code&gt;, &lt;code&gt;bugprone&lt;/code&gt;, &lt;code&gt;performance&lt;/code&gt;, and &lt;code&gt;modernize&lt;/code&gt; checks enforce most of the functional half, run against the C++23 standard the project targets and wired into the build as an opt-in pass the CI and audit builds turn on; on Windows, Microsoft's C++ Core Guidelines Checker does the same job. A complexity checker (I use &lt;code&gt;lizard&lt;/code&gt;, though &lt;code&gt;clang-tidy&lt;/code&gt;'s cognitive-complexity check can do it in-build) guards against functions quietly growing unmaintainable: across the core the average function's cyclomatic complexity is under six, and the handful that run high are the genuinely hard DSP kernels, not accidents. No linter catches every guideline, and some rules are human judgment, but when I say I follow them it's a claim a machine re-checks, not something I'm asking you to take on faith.&lt;/p&gt;

&lt;p&gt;The short version: get a name wrong and you've annoyed a maintainer; get one of these wrong and you've shipped a defect. Both are worth fixing, but only one of them fails at three in the morning, when a device is trying to read a life-saving sensor, or a radio is trying to get an emergency message out.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on precision: read the datasheet
&lt;/h2&gt;

&lt;p&gt;Developers reach for &lt;code&gt;double&lt;/code&gt; by reflex, "double beats float beats int32," but almost none of them open the &lt;em&gt;datasheet of the part feeding them the numbers.&lt;/em&gt; Your samples arrive from a codec or ADC with a hard precision ceiling, and the HF signal itself is buried near the channel noise floor. &lt;code&gt;float32&lt;/code&gt; already carries far more precision than the data ever had, so &lt;code&gt;double&lt;/code&gt; spends cycles and memory refining bits that are pure noise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7phon1giwvlqvwlbhdfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7phon1giwvlqvwlbhdfs.png" alt="Precision ladder: float64, float32, 24-bit codec, 16-bit ADC, HF channel, and decode threshold, in dB of usable dynamic range" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The one place &lt;code&gt;double&lt;/code&gt; &lt;em&gt;does&lt;/em&gt; earn its keep isn't per-sample math; it's a long-running accumulator, like the sample counter from the overview that walks off the grid after about twenty minutes of continuous audio. Match the precision to where the math actually needs it; the datasheet, not habit, tells you where that is.&lt;/p&gt;

&lt;p&gt;That rule cuts the other way, too, and it caught me while I was writing this. My &lt;code&gt;Frequency&lt;/code&gt; type stores its value in &lt;code&gt;double&lt;/code&gt;, not the &lt;code&gt;float&lt;/code&gt; the signal path runs on, and this time that is the right call: a frequency is not per-sample math, it is a setting, and it can be an on-air RF value. &lt;code&gt;float&lt;/code&gt; holds integers exactly only to about 16.7 million, so a 29 MHz dial plus a one hertz nudge quietly rounds straight back to 29 MHz; the single hertz just vanishes. So the quantity that might live at RF gets &lt;code&gt;double&lt;/code&gt;, while the sample stream that never needs it stays &lt;code&gt;float&lt;/code&gt;, and each pays for exactly the precision it uses and no more. The wrapper also carries its own unit, so I can build one frequency in megahertz and another in kilohertz and add them without thinking; inside, both are only ever hertz. Same rule as the codec datasheet, pointed at my own types: match the precision to the quantity, not to a habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right and wrong, in the code
&lt;/h2&gt;

&lt;p&gt;None of this is abstract. Here are four of the rules in real code, three of them straight from the modem's equalizer, next to the version I will not write, and the failure each one removes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors are values, and a buffer carries its own size.&lt;/strong&gt; The flat-channel estimate takes two spans, checks that they agree, and returns a checked result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;nodiscard&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChannelEstimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;estimate_flat_channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IQSampleSpan&lt;/span&gt; &lt;span class="n"&gt;received_probes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IQSampleSpan&lt;/span&gt; &lt;span class="n"&gt;known_probes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;noexcept&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;received_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;received_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;known_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unexpected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;invalid_argument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"probe buffers are invalid"&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version I refuse to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pointer + length: the caller can lie about either, and a bad input has nowhere to be reported&lt;/span&gt;
&lt;span class="n"&gt;ChannelEstimate&lt;/span&gt; &lt;span class="nf"&gt;estimate_flat_channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n_recv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n_known&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failure removed: the buffer overrun from a length that doesn't match its pointer; the caller who swaps &lt;code&gt;n_recv&lt;/code&gt; and &lt;code&gt;n_known&lt;/code&gt;; and the error with nowhere to go. A span carries its data and its length together, so they cannot disagree; the &lt;code&gt;[[nodiscard]] std::expected&lt;/code&gt; makes the caller handle failure instead of dropping it; &lt;code&gt;noexcept&lt;/code&gt; means the audio thread never unwinds. And if &lt;code&gt;std::span&lt;/code&gt; looks like it copies the array on every call, it doesn't: it is just a pointer and a length passed by value, a view into memory the caller still owns, with member functions that keep every access inside the buffer. You get the safety of a bounds-checked container at the cost of passing two registers. And the pointer underneath is not a loophole: because the size rides along, a copy is written against &lt;code&gt;received_probes.size()&lt;/code&gt;, so a size-aware copy (&lt;code&gt;std::ranges::copy&lt;/code&gt; over the span, or &lt;code&gt;std::copy_n&lt;/code&gt; bounded by &lt;code&gt;size()&lt;/code&gt;) physically cannot walk off the end. Compare &lt;code&gt;memcpy(received, src, n)&lt;/code&gt;, where &lt;code&gt;n&lt;/code&gt; is a naked count that will cheerfully run past the buffer the moment it's wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage is fixed and initialized.&lt;/strong&gt; The filter taps are a compile-time-sized array, zero-initialized at the declaration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;maximum_taps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32U&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maximum_taps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mCoefficients&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version I refuse to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;coefficients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// heap on the hot path, uninitialized, and now someone must delete it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failure removed: allocation failure, heap fragmentation, and the nondeterministic latency that blows a real-time deadline; the leak; and the uninitialized read. The &lt;code&gt;{}&lt;/code&gt; starts every tap at a known value, and a fixed &lt;code&gt;std::array&lt;/code&gt; never allocates and can't be freed out from under the receiver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One type, no casts.&lt;/strong&gt; The arithmetic stays in &lt;code&gt;IQSample&lt;/code&gt; from end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A conjugated correlation is an inner product. Use inner_product (a strict left-to-right&lt;/span&gt;
&lt;span class="c1"&gt;// fold), not std::reduce: reduce may reorder the sum, and float addition isn't associative,&lt;/span&gt;
&lt;span class="c1"&gt;// so its result is unspecified and not reproducible build-to-build; at a threshold it can&lt;/span&gt;
&lt;span class="c1"&gt;// even flip a decision. inner_product is deterministic.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt; &lt;span class="n"&gt;cross&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;inner_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;received_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;received_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;known_probes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;IQSample&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;plus&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="p"&gt;[](&lt;/span&gt;&lt;span class="n"&gt;IQSample&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IQSample&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No raw index, so nothing to overrun and nothing for the bounds checker to flag; the intent ("correlate these two") is on the page instead of buried in a loop.&lt;/p&gt;

&lt;p&gt;The version I refuse to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;cross_re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cross_im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// accumulate in double...&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cross_re&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
              &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;imag&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;imag&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;cross_im&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;imag&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
              &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;imag&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;IQSample&lt;/span&gt; &lt;span class="n"&gt;cross&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;cross_re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;cross_im&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;                 &lt;span class="c1"&gt;// ...then narrow it all back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failure removed: the silent promotion to &lt;code&gt;double&lt;/code&gt;, the narrow back to &lt;code&gt;float&lt;/code&gt; on the last line, and every hand-rolled complex-arithmetic slip the standard library already gets right. And watch the cost. On a part with no hardware double, every &lt;code&gt;(double)&lt;/code&gt; conversion and every &lt;code&gt;double&lt;/code&gt; multiply and add is a software-emulated call, not a single FPU instruction. Drop that multiply-accumulate into a tight FFT butterfly and the per-iteration cost stops being one &lt;code&gt;VMLA&lt;/code&gt; and becomes a fistful of emulated-double calls; across an N-point transform that is the difference between making your block deadline and an audio underrun. The one-type version compiles to single-precision &lt;code&gt;VMUL&lt;/code&gt; and &lt;code&gt;VMLA&lt;/code&gt; with nothing in between. When the whole stack speaks one type, there is nothing to cast, and nothing to pay for the cast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Units live in the type.&lt;/strong&gt; A quantity carries its unit, so the compiler speaks radio with you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="n"&gt;modem&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="n"&gt;modem&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;literals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;Frequency&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="n"&gt;_Hz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// not a naked 1800.0f&lt;/span&gt;
&lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;Baud&lt;/span&gt;      &lt;span class="n"&gt;symbol_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2400&lt;/span&gt;&lt;span class="n"&gt;_baud&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// and not the same type as a bit rate&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Power&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dBW&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;     &lt;span class="c1"&gt;// an absolute power level&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Gain&lt;/span&gt;  &lt;span class="n"&gt;amp&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dBi&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;     &lt;span class="c1"&gt;// a relative gain&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Power&lt;/span&gt; &lt;span class="n"&gt;eirp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// Power + Gain -&amp;gt; Power (amplification): 23 dBW&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Db&lt;/span&gt; &lt;span class="n"&gt;snr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;eirp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;Power&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dBW&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;   &lt;span class="c1"&gt;// Power - Power -&amp;gt; a ratio in dB&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Power&lt;/span&gt; &lt;span class="n"&gt;oops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eirp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// Power + Power: this line does not compile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;tx + amp&lt;/code&gt;. &lt;code&gt;tx&lt;/code&gt; is in dBW, an absolute power level; &lt;code&gt;amp&lt;/code&gt; is in dBi, an antenna gain. Those are different units, and adding them by hand is the sort of thing that should make you nervous. But a power level plus a gain is a new power level; that is what amplification is. So the result comes back a &lt;code&gt;Power&lt;/code&gt;, in dBW, and I never carried the units in my head; the compiler did the dimensional bookkeeping. It is the difference between adding a temperature to a temperature change, which is fine, and adding two temperatures, which is not.&lt;/p&gt;

&lt;p&gt;That last line is two absolute levels, and there is no meaningful sum of two power levels, the same way "nine o'clock plus noon" is not a time. So I made it impossible to write. The decibel type's &lt;code&gt;operator+&lt;/code&gt; carries a constraint, &lt;code&gt;requires(!std::same_as&amp;lt;Tag, PowerTag&amp;gt;)&lt;/code&gt;, so for an absolute &lt;code&gt;Power&lt;/code&gt; it is not even a candidate; and I never wrote a free &lt;code&gt;Power + Power&lt;/code&gt;. (I did write &lt;code&gt;Power - Power&lt;/code&gt;, because the difference of two levels is a meaningful ratio, the &lt;code&gt;snr&lt;/code&gt; line above.) Ask for the sum and the compiler looks for an &lt;code&gt;operator+&lt;/code&gt; that takes two power levels, finds none, and stops. It is not an assert and not a runtime check; the program that does the wrong thing cannot be built. That is what C++20's &lt;code&gt;requires&lt;/code&gt; clauses buy you: "this operation is not defined for this type" becomes something the compiler enforces.&lt;/p&gt;

&lt;p&gt;Failure removed: the transposed unit, the hertz read as a kilohertz, the gain quietly added to a level as if they were the same thing, and the dimensionally meaningless operation that a comment would have let slide.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on &lt;code&gt;void*&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;There is one more cast worth calling out by name, because it hides so well: the &lt;code&gt;void*&lt;/code&gt;. It almost never belongs in C++. A &lt;code&gt;void*&lt;/code&gt; is not really a way to pass "anything"; it is a cast you have agreed to make later, at the far end, out of the compiler's sight. It throws the type away at exactly the point you most needed it, and the receiver buys it back with a &lt;code&gt;static_cast&lt;/code&gt; the compiler cannot check. Hand that callback the wrong object and it still compiles, then does something undefined at runtime, far from the mistake. It also splits one idea into two things that must travel together and can silently disagree: a function pointer and its context. An interface binds the behavior and the object into a single typed thing that cannot be mismatched.&lt;/p&gt;

&lt;p&gt;I caught myself on this while writing modem-core. One receiver's callback had grown up as a function pointer plus a &lt;code&gt;void* context&lt;/code&gt;, the C way; most of the families still did it that way, and the easy move was to make the odd one out match the majority. I started to, then stopped and asked the question that mattered: do these C++ interfaces actually need &lt;code&gt;void*&lt;/code&gt;? They did not. The C module contract is a separate, pure-C header with its own structs; the application links the C++ code directly, and no C caller could touch these interfaces anyway. So the alignment ran the other way. Every family moved &lt;em&gt;up&lt;/em&gt; to a small typed interface, an abstract class with a couple of &lt;code&gt;noexcept&lt;/code&gt; virtual calls, and the &lt;code&gt;void*&lt;/code&gt; stayed only where real C lives. The casts disappeared because the type never got lost.&lt;/p&gt;

&lt;p&gt;The "almost" is real, and it is narrow. &lt;code&gt;void*&lt;/code&gt; earns its place at a genuine C boundary: the STM32 HAL, an RTOS like FreeRTOS, a Win32 or POSIX callback, or a frozen C ABI of your own. C has no templates and no virtual functions, so an opaque user-data pointer is simply the idiom there, and you make your peace with it. Even then you wrap it once, in one small adapter that does the single documented cast and hands typed references to everything behind it; exactly one place in the codebase ever sees the &lt;code&gt;void*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat that is easy to miss on a microcontroller, because it is where I have watched good intentions backfire. Removing a &lt;code&gt;void*&lt;/code&gt; only helps if what replaces it costs nothing. On a host you would reach for &lt;code&gt;std::function&lt;/code&gt;; on a &lt;code&gt;-fno-exceptions&lt;/code&gt;, no-heap signal path you cannot, because it allocates. The type-safe replacement has to be the zero-overhead kind: a template resolved at compile time, or a non-owning virtual interface, whose virtual call is a single indirect call, the very same instruction the function pointer was already costing you. Trade a &lt;code&gt;void*&lt;/code&gt; for &lt;code&gt;std::function&lt;/code&gt; and you have swapped a type hole for a heap allocation, which on the audio thread is the worse of the two. Avoid the &lt;code&gt;void*&lt;/code&gt;, but reach for the tool that keeps the deadline, not the one that quietly breaks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn it upside down
&lt;/h2&gt;

&lt;p&gt;That decision about &lt;code&gt;void*&lt;/code&gt; is worth one more look, not for the answer but for how it got there. The first instinct was to make the odd interface match the majority; the better answer only showed up when I stopped and asked the opposite question: what if I go the other way? What if the majority is the thing that's wrong? Aligning &lt;em&gt;up&lt;/em&gt; instead of down was sitting right there the whole time, but I could not see it until I turned the problem over.&lt;/p&gt;

&lt;p&gt;I learned that trick somewhere unexpected. I once took an art class, and I am genuinely terrible at drawing (most AI agents are no better, for what it's worth). The instructor, a real master, watched me struggle and gave me one piece of advice that has outlasted everything else from that room: turn the thing you are drawing upside down, and draw it upside down. Your brain stops insisting it already knows what a hand or a chair is &lt;em&gt;supposed&lt;/em&gt; to look like, and you finally draw the lines that are actually there. Turn it sideways. Take the negative and draw the empty space instead of the object. What do you see now that you could not a moment ago?&lt;/p&gt;

&lt;p&gt;Code review is the same discipline. The assumption you cannot see is the one doing the damage, and you do not dislodge it by staring harder from the same angle. So I keep asking "what if?" What if the data flowed the other direction? What if this owner should be the caller instead of the callee? What if the thing everyone treats as fixed is the one thing to change? Most of the time the answer comes back "no, the first way was right," and that is fine; you have paid a few minutes to be sure. But often enough the upside-down view shows you the line that was there all along, the one you would never have drawn facing the page the usual way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism, or the same answer twice
&lt;/h2&gt;

&lt;p&gt;Here is the hard one, the rule underneath the rules. For a system you have to verify, certify, and someday debug from a field failure, the same input must produce the same output: on this build, on the next run, and on a colleague's machine a year from now. Determinism is the precondition for proof. You cannot verify, trust, or certify a result you cannot reproduce.&lt;/p&gt;

&lt;p&gt;That is why the &lt;code&gt;std::reduce&lt;/code&gt; question earlier is not pedantry. Its result is unspecified: the standard lets it group the sum any way it likes, and because floating-point addition isn't associative, "any way it likes" can mean a different answer on a different day. The same trap hides in &lt;code&gt;-ffast-math&lt;/code&gt;, which quietly reassociates your arithmetic; in relying on argument evaluation order; in reading uninitialized memory; in undefined behavior of any kind, a signed overflow, a shift off the end of a type, a strict-aliasing violation, each of which licenses the compiler to do literally anything, and "anything" is rarely the same thing twice; and in anything that depends on timing or thread scheduling. None of these are wrong the way a crash is wrong. They are worse: they pass every quick test and then diverge later, under a different compiler, a higher optimization level, or the one machine you can't get your hands on.&lt;/p&gt;

&lt;p&gt;And it isn't only about ordering. The &lt;code&gt;float32&lt;/code&gt; sample counter from earlier, the one that walks off the grid after twenty minutes, is the same disease in another form: same code, same input, correct and then incorrect, determinism lost across time rather than across runs. A two-minute test never sees it; a five-hour soak always does.&lt;/p&gt;

&lt;p&gt;So determinism gets designed in, then proven. Designed in: one &lt;code&gt;Real&lt;/code&gt; type so a build can't silently promote and shift the answer, a fixed left-to-right fold instead of a reordering reduce, no &lt;code&gt;-ffast-math&lt;/code&gt;, every object initialized. Proven: bit-exact golden vectors and an independent reference decoder that has to produce the same bytes, checked on every build. The goldens aren't there because I doubt the math; they are there because "the same answer twice" is a property you can only keep by testing for it. Correctness you can often eyeball. Determinism you cannot, which is exactly why it's the one that bites in the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  "C++ is too slow": a myth worth retiring
&lt;/h2&gt;

&lt;p&gt;There's a reflex in embedded circles that "C++ is too slow for a microcontroller." I'd put the opposite on the table: C++ written &lt;em&gt;this&lt;/em&gt; way (templates resolved at compile time, no dynamic memory, no RTTI, no exceptions) is almost certainly &lt;strong&gt;safer&lt;/strong&gt; than the equivalent hand-rolled C, and arguably just as &lt;strong&gt;fast&lt;/strong&gt;, in a comparable memory footprint. The abstractions cost nothing at runtime because they're gone by the time the linker runs; what's left is the same machine code a careful C programmer would have written, but with the compiler enforcing the invariants instead of a code review hoping to catch them. Code that reads like a specification, runs in a few kilobytes of workspace, and doesn't make you choose between safety and speed. "Tight and fast" isn't a slogan; it's what the target demands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honesty cuts both ways
&lt;/h2&gt;

&lt;p&gt;Not every algorithm fits every part. The model-based CIR equalizer compiles &lt;em&gt;out entirely&lt;/em&gt; on embedded targets to reclaim ~14 KiB, and the whole-burst turbo decoder that earns the standard's conformance corners at zero errors is a host-side path; on the microcontroller, the leaner adaptive decision-feedback equalizer is the sole receiver, a decision expressed in one build flag rather than a forked codebase. That's a real tradeoff, not a free lunch: the embedded receiver gives up some of the turbo path's fading margin to fit the part's memory and clock budget. But the ceiling there is &lt;em&gt;this particular part's&lt;/em&gt; budget, not embedded computing itself; every processor has a ceiling, and a bigger part or a co-processor moves it. I'll quantify what the tradeoff costs, and whether more capable hardware (a larger part, or the FPGA co-processor I've prototyped alongside the M33) buys the turbo path back into budget, when I reach the equalizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A word on "safety"
&lt;/h2&gt;

&lt;p&gt;There have been press conferences and position statements arguing that one language is inherently safer than another. The fact is quieter: safety is far more about using the tool correctly than about which tool you picked. That framing confuses the tool with the hand holding it. A language isn't unsafe on its own; code becomes unsafe through undisciplined practice: the raw pointer no one bounds-checked, the buffer no one sized, the exception no one expected. The guardrails are right there in the language; unsafe code comes from engineers who don't use them, or who deliberately switch them off. A power saw hurts you when the operator won't lower the guard into place; the saw isn't the defect, the choice is. The disciplines I hold to in this project (no heap in the signal path, checked &lt;code&gt;[[nodiscard]]&lt;/code&gt; results instead of surprises, one unified type, casts only at the door) are those guardrails, used on purpose. And modern C++ hands you the rest directly: &lt;code&gt;std::span&lt;/code&gt; and &lt;code&gt;std::ranges&lt;/code&gt; for bounds-safe access instead of raw pointer arithmetic, &lt;code&gt;std::array&lt;/code&gt; for fixed storage, and RAII with &lt;code&gt;std::unique_ptr&lt;/code&gt; for ownership you cannot leak. C++23 is a perfectly capable language for safety- and mission-critical work; you just have to pick up the tools it already gives you. The safe choice isn't a rewrite; it's the practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the rulebooks: MISRA, AUTOSAR, JSF
&lt;/h2&gt;

&lt;p&gt;Someone in a regulated shop is already asking the obvious question: where is MISRA? This is medical and military work, and MISRA C++ (now MISRA C++:2023), AUTOSAR C++14, and JSF are the coding standards certification bodies ask for by name. I have been pointing at the C++ Core Guidelines instead.&lt;/p&gt;

&lt;p&gt;Here is my honest position. Write code the way I am describing, and use the Core Guidelines the way they are meant to be used, as a way of thinking and not a checklist, and you are not falling short of those rulebooks; you are past them. The rules in this article are stricter than most of what those standards enforce, and having clang-tidy and lizard check them on every build is a stronger guarantee than a signature attesting that someone read the standard once. A result a machine re-derives on every commit beats a box a human ticked at the end.&lt;/p&gt;

&lt;p&gt;I will go further, because I have watched it happen. A great deal of "MISRA-compliant" code is not compliant in spirit at all; it is a pile of suppressed warnings and blanket deviations. The rule fires, the deadline is close, and the fastest way to green is a justification comment and a suppression, not a fix. The standard is satisfied on paper while the very thing it warned about stays in the code. That is the failure mode of any rulebook used as a checklist: it rewards making the warning disappear, not making the defect disappear.&lt;/p&gt;

&lt;p&gt;My rules push the other way. When the analyzer flags something, the move is to change the code until the reason for the warning is gone, not to annotate it into silence. Address the root cause and the deviation log stays short and honest. The goal was never to satisfy a document; it was to make a class of failure impossible. Do that in earnest and the certificate takes care of itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on getting it wrong
&lt;/h2&gt;

&lt;p&gt;I should be honest about all of this: I don't always get it right the first time. I slip back into old habits, I race to get something working, and then I look up and find a raw index where a span belongs, or a function that quietly grew three jobs while I wasn't watching. The rules above are not how my first draft comes out. They are what I hold myself to on the way from "it works" to "it's right."&lt;/p&gt;

&lt;p&gt;Code is iterative, like anything worth doing. You can absolutely write software this way, anyone can, but not in one pass and not on good intentions alone. It takes real work, real time, and honest iteration: write it, measure it, run the linter over your own code, and fix what it finds. That last part is the whole point of the tools; they catch me when I've slipped, they are not a trophy for never slipping. The discipline isn't being perfect. It's going back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is what "safety-critical" means
&lt;/h2&gt;

&lt;p&gt;Step back from the individual rules and the argument is simple. "Mission-critical" and "safety-critical" are not adjectives you earn by trying hard; they are a standard of evidence. The question is never "is the code good," it's "can you show that a whole class of failure cannot happen." Everything above exists to answer that question, one failure mode at a time.&lt;/p&gt;

&lt;p&gt;Read the rules in that light and they stop being style. No heap on the signal path removes allocation failure, fragmentation, and the nondeterministic latency that blows a real-time deadline. No exceptions removes an invisible control path through code that must never unwind on the audio thread. Checked results, in an error type you cannot silently drop, remove the error a caller forgot to handle. One type, with casts only at the door, removes the silent narrowing and the signed/unsigned slip; a quantity that carries its unit removes the unit mismatch and the transposed argument. A &lt;code&gt;std::array&lt;/code&gt; that carries its length removes the buffer overrun; a bounded, recursion-free stack removes the silent overflow on a part with no memory protection; initializing every object removes the uninitialized read; a &lt;code&gt;static_assert&lt;/code&gt; removes the wrong assumption that would otherwise wait until runtime to matter; small, low-complexity functions remove the path no test ever covered. Each rule turns a possible defect into an impossible one, at compile time or by construction. That is the whole game: you don't hope the bug isn't there, you arrange for it to be unrepresentable.&lt;/p&gt;

&lt;p&gt;And then you prove it, which is where the tooling stops being a convenience and becomes the point. A safety process does not accept "we were careful"; it wants a demonstration it can repeat. The compiler's warnings-as-errors gate every build, and clang-format, clang-tidy, and lizard run as a build-integrated pass, so the discipline isn't a story I tell in a design review, it's a result a machine reproduces. Careful is a feeling; a failing build is a fact.&lt;/p&gt;

&lt;p&gt;That is why disciplined modern C++ belongs in mission-critical systems, and why I keep the rules boring and the tools honest. The exciting part is the DSP. The rules are what let me sleep while the modem runs for five hours straight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;With the rules on the table, next week I start at the top of the signal chain: the transmit side, the easy half done right.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>programming</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>Building a MIL-STD-188-110 HF Modem in Modern C++: An Introduction</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sat, 26 Sep 2026 16:06:02 +0000</pubDate>
      <link>https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0</link>
      <guid>https://dev.to/kg7hf/building-a-mil-std-188-110-hf-modem-in-modern-c-an-introduction-6l0</guid>
      <description>&lt;p&gt;&lt;strong&gt;A weekly deep dive into a fixed-frequency serial-tone waveform, one block at a time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;High-frequency (HF) radio is the communications system that refuses to die, and for good reason. It bounces signals off the ionosphere to reach thousands of miles with no satellites, no cell towers, and no infrastructure an adversary can switch off. That resilience is exactly why defense communications still standardize on it, and why &lt;strong&gt;MIL-STD-188-110&lt;/strong&gt;, the interoperability standard for HF data modems, is still very much alive, from its 1991 "A" revision through the current "D."&lt;/p&gt;

&lt;p&gt;Picture trying to pick out one friend's voice across a crowded room. Now make the room an echo chamber, so every word reaches you two or three times, each arriving a beat apart, and let the crowd swell and hush at random, so your friend fades to a whisper and back mid-sentence. That is the HF channel. And here's the part the textbooks written in the 1980s didn't have to reckon with: the room is louder now than it has ever been. The early radio pioneers listened into a quiet spectrum. We've filled ours with switch-mode power supplies, LED drivers, and phone chargers, each one humming broadband electrical hash across the very bands the ionosphere hands us for free. A modem that still works over &lt;em&gt;that&lt;/em&gt; is a small master class in digital signal processing. Over the next several weeks I'm going to take mine apart, the &lt;strong&gt;serial-tone (single-tone) waveform of MIL-STD-188-110A/B (110B §5.3.2)&lt;/strong&gt;, and walk through every block of it.&lt;/p&gt;

&lt;p&gt;And there's a second reason it matters, the one that turns a clever demo into something you can actually field: the whole modem runs on a microcontroller, not a rack of specialized radio hardware. For the modern warfighter that's less about cost than about &lt;strong&gt;weight and battery life&lt;/strong&gt;. A Cortex-M33 is a part you can put to sleep, so ours sits in deep sleep and only wakes when there's activity on the channel; the radio you carry gets lighter, lasts longer between charges, and still never misses a call. Resilient, low-power, and small enough to disappear into a handheld is a rare combination, and it's the one I care about most; I've spent my career in safety- and mission-critical codebases, the kind where a defect isn't a bug ticket, and this modem is that same rigor aimed at HF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waveform in one breath
&lt;/h2&gt;

&lt;p&gt;If the acronyms in this section aren't your world, don't sweat them; the single idea to carry out of it is that every one of them is a deliberate trade of raw speed for survivability.&lt;/p&gt;

&lt;p&gt;The serial-tone body is deceptively simple on paper. Everything rides on a &lt;strong&gt;single 1800 Hz carrier at 2400 symbols per second&lt;/strong&gt;, using &lt;strong&gt;8-PSK&lt;/strong&gt; modulation, at data rates from &lt;strong&gt;75 to 4800 bps&lt;/strong&gt;. Lower rates buy robustness by spending bits on redundancy: a &lt;strong&gt;K=7 rate-1/2 convolutional code&lt;/strong&gt; protects the data, with symbol &lt;strong&gt;repetition&lt;/strong&gt; at 300 and 150 bps and &lt;strong&gt;Walsh&lt;/strong&gt; spreading at 75 bps. A block &lt;strong&gt;interleaver&lt;/strong&gt; (0.6 s "short" or 4.8 s "long") scatters the bits so a fade burst damages many codewords a little instead of one codeword fatally. Known &lt;strong&gt;probe&lt;/strong&gt; symbols are stitched into the data stream so the receiver can keep measuring the channel, and the whole burst opens with a &lt;strong&gt;countdown preamble&lt;/strong&gt; that announces the mode and marks where the data begins.&lt;/p&gt;

&lt;p&gt;That's the transmitter, and honestly it's the easy half. Encoding is deterministic. The hard half is undoing what the channel did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real receiver actually looks like
&lt;/h2&gt;

&lt;p&gt;If you've seen the classic block diagram of this waveform, a clean left-to-right chain of equalizer to symbol extraction to deinterleave to Viterbi, you've seen an &lt;em&gt;honest simplification&lt;/em&gt;. The standard even ships its own version (Figure 2, the serial-tone functional block diagram), and like every textbook rendering it assumes the receiver already knows where the burst starts and what mode it is. A real receiver has to earn all of that from raw audio and noise.&lt;/p&gt;

&lt;p&gt;And that gap is the whole game. I've seen plenty of implementations that decode a clean, textbook waveform beautifully... and then fall over the moment the channel does anything real: a little noise, a fade, an echo, a few hertz of drift. The clean case is the easy ten percent. The hard ninety is the error correction, the equalization, and the timing and carrier tracking that keep a link alive when the ionosphere is working against you. Meeting the standard under those conditions is genuinely hard; exceeding it is harder still, and that's exactly what this series spends its time on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2jrz3fvmb6deii92lzkx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2jrz3fvmb6deii92lzkx.png" alt="The nine-stage receive signal chain, from 48 kHz audio in to payload bytes out" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The receive chain I built has nine stages, and the interesting ones are the three the textbook version glosses over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Acquisition&lt;/strong&gt; doesn't assume sync; it &lt;em&gt;finds&lt;/em&gt; the burst. It correlates against the known preamble in a way that survives fast fading, and decodes the mode and a countdown that tells a receiver waking up mid-preamble exactly how far ahead the data is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Earliest-path anchoring&lt;/strong&gt; solves a subtle multipath trap: on a two-path channel you can lock onto the &lt;em&gt;echo&lt;/em&gt; instead of the direct signal, which puts the real energy somewhere the equalizer can't reach. So I lock onto the direct path and treat the echo as energy the equalizer can &lt;em&gt;use&lt;/em&gt;, not interference it can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The equalizer&lt;/strong&gt; is where my design diverges most from a reference model. Instead of one fixed adaptive filter, I pick the equalizer's geometry from the &lt;em&gt;measured&lt;/em&gt; delay spread, and solve the channel one of three ways: an adaptive decision-feedback equalizer for the streaming/embedded path, a model-based CIR-MMSE equalizer that measures the channel and solves the optimal filter in one shot, and a whole-burst turbo decoder for the strongest possible decode. The insight behind it: a 20-symbol probe can't train a 46-tap adaptive filter on a fast-fading channel, but it easily over-determines a 13-tap channel &lt;em&gt;model&lt;/em&gt;, so measuring the channel and solving the filter is the stronger move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything after that carries &lt;em&gt;soft&lt;/em&gt; information end to end: soft demapping to log-likelihood ratios, deinterleaving, repetition combining, and a continuous Viterbi (or a soft-in/soft-out decoder for the turbo loop). The decoder gets probabilities, not just hard guesses.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does the extra machinery earn its keep? Here's the number.&lt;/strong&gt; Across every conformance corner the test plan defines for this waveform (Table 10.1 of the JITC conformance test plan, which verifies the 110B serial-tone performance requirements), my whole-burst turbo receiver decodes at &lt;strong&gt;zero bit errors&lt;/strong&gt;, comfortably clearing a bar that only asks for fewer than one error in 100,000. That includes the punishing low-rate case of a 5-millisecond multipath echo under a 5 Hz fade, the condition the plan sets for 75, 150, and 300 bps. That worst-case corner is the one where two equal-strength copies of the signal arrive a beat apart and the channel breathes in and out five times a second; it's the corner that separates a demo from a modem. As one example of &lt;em&gt;why&lt;/em&gt; it holds up: simply deciding to lock onto the &lt;strong&gt;direct&lt;/strong&gt; signal instead of its louder echo cut the error rate roughly &lt;strong&gt;tenfold&lt;/strong&gt; on a 300 bps two-path fading channel. Every figure in this series comes with its channel and SNR attached, because on HF a bit-error rate without a channel is a number without a meaning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Conformance is the floor, not the ceiling
&lt;/h2&gt;

&lt;p&gt;Passing MIL-STD-188-110 is the price of admission; the interesting engineering lives in the &lt;em&gt;margin&lt;/em&gt; above it. So I built a battery of my own tests that push well past what the standard asks: faster fading than the spec's worst corner, carrier offsets wider than it tolerates, and in-band interference it never contemplates at all. The modem holds zero errors across conditions the standard doesn't even test. That margin isn't a trophy; it's what keeps a link up when the real world is worse than the spec promised: more range on a bad night, messages that still get through under jamming and the electrical junk no test plan models. I'm not going to dump those numbers here; each one belongs with the block of the receiver that earns it, so I'll bring them out as we go and assemble the whole picture in the finale. But the headline is worth stating plainly up front: &lt;strong&gt;meeting the standard was never the goal; beating it was.&lt;/strong&gt; That's a theme I'll keep coming back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard to fake: hours, not minutes
&lt;/h2&gt;

&lt;p&gt;Here's what separates a real modem from a weekend project: endurance. Anyone can write something that decodes a clean 30-second test tone. The MIL-STD / JITC conformance regime doesn't ask for 30 seconds; it asks for &lt;em&gt;hours&lt;/em&gt; of continuous, unbroken bitstream at a target error rate, over a channel that is actively fighting you the entire time. The plan isn't coy about the duration, either: it calls for each fading condition to be measured for at least &lt;strong&gt;five hours&lt;/strong&gt;, and even the clean AWGN case for at least an hour. Just to &lt;em&gt;measure&lt;/em&gt; a bit-error rate of 1 in 100,000 with statistical confidence you need on the order of ten million bits, and at these data rates that's a stream running for tens of minutes to hours without a single stumble.&lt;/p&gt;

&lt;p&gt;A toy modem sails through the first few minutes and then quietly comes apart: a timing loop drifts, an accumulator overflows, a fade it can't ride knocks it off the channel. Passing means actually implementing the advanced DSP (adaptive equalization, carrier and symbol-timing tracking, interleaving deep enough to outlast a fade) that keeps the stream locked for the &lt;em&gt;full duration&lt;/em&gt;. I found this out the hard way: a sample-index counter I'd kept in &lt;code&gt;float32&lt;/code&gt; walks off the symbol grid after about twenty minutes of continuous audio (once it passes 2^26 samples, where the gap between representable integers grows past the step size). A two-minute test can never surface that. The hours-long soak isn't an inconvenience in the test plan; it &lt;em&gt;is&lt;/em&gt; the test.&lt;/p&gt;

&lt;p&gt;On top of duration, the channel itself escalates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test channel&lt;/th&gt;
&lt;th&gt;What it simulates&lt;/th&gt;
&lt;th&gt;Why it's punishing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWGN (Gaussian)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Thermal noise only, no fading&lt;/td&gt;
&lt;td&gt;The baseline, yet the low rates must still decode at just a few dB of SNR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Doppler shift / offset&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Radio tuning error, oscillator drift, ionospheric motion&lt;/td&gt;
&lt;td&gt;Must hold sync with the carrier offset +/-75 Hz, and in the sweep test, ramping +/-75 Hz at 3.5 Hz/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Watterson 2 ms / 1 Hz&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Moderate two-path multipath, slow fade&lt;/td&gt;
&lt;td&gt;Two signal copies ~5 symbols apart; the equalizer must exploit the echo, not fight it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Watterson 5 ms / 5 Hz&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Severe multipath, &lt;em&gt;fast&lt;/em&gt; fade (low-rate corner)&lt;/td&gt;
&lt;td&gt;Echo 12 symbols deep &lt;em&gt;and&lt;/em&gt; the channel changing 5x a second; the low rates (75-300 bps) must survive exactly this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Continuous duration&lt;/strong&gt; (&amp;gt;=1 hr AWGN, &amp;gt;=5 hr fading)&lt;/td&gt;
&lt;td&gt;A real operational link held open for hours&lt;/td&gt;
&lt;td&gt;Every drift, overflow, and timing walk compounds over millions of symbols; only sustained DSP survives it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  But it has to run on a microcontroller
&lt;/h2&gt;

&lt;p&gt;There's one more constraint that shaped every decision you'll read about in this series, and it's worth naming up front: none of this was written just to run on a workstation. The whole modem cross-compiles to embedded microcontrollers, Cortex-M33 and M7-class parts, and that single rule, more than any other, is why the code looks the way it does. No heap in the signal path. No exceptions. One numeric type, chosen to match the hardware rather than habit.&lt;/p&gt;

&lt;p&gt;That's an entire engineering philosophy of its own: the coding guidelines I hold myself to, why modern C++ is a genuinely capable choice for this kind of work, and how you get memory-safety-grade discipline without throwing away thirty years of tooling. It's too big to bury in an introduction, so it's where the series actually starts. Next week: the rules I build by.&lt;/p&gt;

&lt;h2&gt;
  
  
  The series ahead
&lt;/h2&gt;

&lt;p&gt;Each week I'll take one piece and go deep, looking at the DSP, the standard's intent, the failure modes on a bad channel, and the choices that keep it small enough to embed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The rules I build by.&lt;/strong&gt; The coding guidelines, no heap and no exceptions in the signal path, one numeric type, disciplined error handling, and what makes modern C++ the right tool for the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transmit chain.&lt;/strong&gt; Framing, the countdown preamble, and turning bits into an 1800 Hz waveform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audio front end.&lt;/strong&gt; Matched filtering and Gardner symbol timing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acquisition.&lt;/strong&gt; Finding a burst in noise, and reading the mode from a countdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Earliest-path anchoring.&lt;/strong&gt; Why you lock onto the direct path, not the loudest one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The equalizer.&lt;/strong&gt; Adaptive vs. model-based, why measuring the channel beats chasing it, and what actually fits a microcontroller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft demapping and the FEC.&lt;/strong&gt; LLRs, continuous Viterbi, and the turbo loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration.&lt;/strong&gt; The streaming state machine that makes it a live receiver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing and channel simulation&lt;/strong&gt; &lt;em&gt;(a side journey)&lt;/em&gt;. Standing up the channel simulators (Watterson / PathSim), and how you get repeatable, statistically meaningful BER-vs-SNR results instead of a lucky decode. This one turned out complex enough to earn its own deep dive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conclusion: the whole modem, measured.&lt;/strong&gt; The complete results table in one place: conformance, and everything the receiver does beyond what the standard asks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you build HF systems, write real-time C++, or just like watching a hard problem get taken apart cleanly, follow along.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;About the author.&lt;/em&gt; Paul Decker (amateur radio callsign KG7HF) is a mission-critical, safety-critical embedded developer with thirty years of microcontroller experience, the greenfield kind, where a defect isn't a bug ticket. He served as a radioman aboard Trident ballistic-missile submarines, worked in the DSP division at Analog Devices, and has built safety-critical medical devices and mission-critical military systems. On the amateur side he's a dedicated homebrewer: antennas, amplifiers, and a long line of embedded controllers for ham gear. He works weak-signal VHF/UHF, including EME ("moonbounce"), and operates in the Army MARS (Military Auxiliary Radio System) program. He's an engineer and a builder, not an algorithm researcher: this modem isn't a research project, it's an engineered one, built to run on a microcontroller and proven against the standard.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>dsp</category>
      <category>hamradio</category>
      <category>amateurradio</category>
    </item>
  </channel>
</rss>
