<?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>The equalizer</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sun, 01 Nov 2026 22:57:06 +0000</pubDate>
      <link>https://dev.to/kg7hf/the-equalizer-3e10</link>
      <guid>https://dev.to/kg7hf/the-equalizer-3e10</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 6 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;, 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;, Part 4, &lt;a href="https://dev.to/kg7hf/acquisition-finding-the-burst-i1l"&gt;acquisition&lt;/a&gt;, or Part 5, &lt;a href="https://dev.to/kg7hf/the-first-path-2eje"&gt;the first path&lt;/a&gt;. The code for this article is on GitHub: &lt;a href="https://github.com/kg7hf/modem-core/tree/part-6" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 5 ended with an anchor on the first path, a carrier estimate, the mode and the body boundary. From those, the position of every symbol of the body is known. The symbols still cannot be read. On Table XX's channels each received symbol is the transmitted one plus a copy of a symbol sent 4.8 or 12 symbols earlier, and both copies fade on their own. Read at the anchor, a symbol is two symbols added together, with weights that change several times a second.&lt;/p&gt;

&lt;p&gt;Undoing that is the equalizer's job. It has help: every frame of data is followed by known symbols, the probes, so the receiver can keep checking its work against symbols it does not have to guess. There are two ways to use them. One is to follow the channel: keep a filter that undoes it, and nudge the filter's taps every time a known symbol shows how far off it is. The other is to measure the channel: estimate from each probe what the channel did to it, and solve for the filter that undoes that. This article builds both, and measures them against each other on the same recordings.&lt;/p&gt;

&lt;p&gt;Part 5's last paragraph promised a third way, the whole-burst turbo equalizer. It needs the decoder, which is Part 7's subject, so it moves there.&lt;/p&gt;

&lt;p&gt;Part 5 had &lt;code&gt;first_path_demo&lt;/code&gt;; this article has &lt;code&gt;equalizer_demo&lt;/code&gt;. It runs on the same recordings, so a build that fetched them for Part 5 has them already:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cmake &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DMODEM_ARTICLE_DATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ON
cmake &lt;span class="nt"&gt;--build&lt;/span&gt; build
./build/equalizer_demo
&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;== Equalizer walkthrough, on the article's recordings ==
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;delay_spread()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How far behind the anchor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;window()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A window sized from the measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;training()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Training on the preamble&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adaptive()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Following the channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cir_mmse()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Measuring the channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fade()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the channel moves&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As before, &lt;code&gt;tests/equalizer_walkthrough_tests.cpp&lt;/code&gt; calls the same functions and checks what they returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Since Part 5
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The turbo equalizer moves to Part 7&lt;/strong&gt;, beside the decoder it runs with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first clang-tidy cleanup&lt;/strong&gt; of the modem reached the published files: constants from &lt;code&gt;std::numbers&lt;/code&gt;, loop counters as wide as the values they count to, and no mutable global for the error handler. No output changed; the whole-burst audio test checks that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing for contributors.&lt;/strong&gt; One command now builds and runs a named suite: &lt;code&gt;cmake -DSUITE=quick -P tools/test.cmake&lt;/code&gt; runs the unit and golden tests, &lt;code&gt;article&lt;/code&gt; adds the recordings' tests and the demos (fetching the exact release package if it is missing), and &lt;code&gt;full&lt;/code&gt; runs everything the repository registers. A Dockerfile carries the compilers the CI uses, GCC 14 and Clang 19, for anyone who would rather not install them. The recordings' check is itself tested: a damaged, incomplete or padded package must be refused. Every pull request runs the quick suite with both compilers and the article suite in Docker. &lt;code&gt;docs/testing.md&lt;/code&gt; has the commands. This article's tests join those suites.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How far behind the anchor
&lt;/h2&gt;

&lt;p&gt;The equalizer needs to know how long the channel is before it can size itself. Part 5 searched the lags ahead of the anchor for an earlier path. &lt;code&gt;estimate_body_preamble_delay_spread()&lt;/code&gt; runs the same kind of search behind it. It correlates the known prefix at lags 0 to 15 symbols, in every preamble segment from the acquired one to the body. It adds the powers across segments, so a path that fades through one segment still counts. Then it subtracts the prefix's own sidelobes; the same idea as Part 5's model, built here from the symbols that follow the prefix instead of the ones before it.&lt;/p&gt;

&lt;p&gt;What is left is a power for each lag. Lags 0 to 2 belong to the main path: a path that arrives between two symbol times splits its power over neighbouring lags, so those three are never counted as an echo. Echoes cast sidelobes too, so before the scan the estimator removes those as well (more on that below). Then, from lag 3 on, the delay spread is the largest lag whose power reaches an eighth of the strongest (about 9 dB down). If none does, the answer is 0, one clean path.&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="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;cancel_echo_sidelobes&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="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_table_size&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;lag_powers&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;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="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_table_size&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sidelobe_powers&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;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;significant_power&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;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="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_table_size&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sidelobes_removed&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;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;main_path_lobe_lags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;pass&lt;/span&gt;&lt;span class="p"&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="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;echo_lag&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="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;echo_power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0F&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;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;main_path_lobe_lags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&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;sidelobes_removed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;lag_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;echo_power&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo_power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lag_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
                &lt;span class="n"&gt;echo_lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lag&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;echo_lag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0U&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;echo_power&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;significant_power&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="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;sidelobes_removed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;echo_lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&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;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;echo_lag&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;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;lag_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&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="n"&gt;sidelobe_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;echo_lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sidelobe_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;echo_power&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;span class="c1"&gt;// ... and in estimate_body_preamble_delay_spread(), after the main path's sidelobes are gone:&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;significant_power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;significant_path_fraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;strongest_power&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;cancel_echo_sidelobes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag_powers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sidelobe_powers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;significant_power&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;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;main_path_lobe_lags&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&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;lag_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;significant_power&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;delay_spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&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;That rule is the code's second, and changing to it uncovered a third piece. Until recently the estimator took the strongest echo, not the last one, and checked it against the same bar, while every comment around it described the largest. Nothing caught it, because on the standard's channels the two rules agree: a two-path channel has one echo, and it is both the strongest and the last. A third path tells them apart. Put a strong echo at 5 symbols and a weaker but real one at 12, and the old rule says 5, keeps the short window, and leaves the path at 12 outside it. The window has to hold every path that matters, so the rule became the largest.&lt;/p&gt;

&lt;p&gt;Switching to it exposed a case the old rule never reached. The prefix repeats every 8 symbols, which is why the main path's sidelobes appear 8 and 9 lags behind it. An echo is a copy of the same prefix, so it casts the same sidelobes 8 and 9 lags behind itself: an echo at 5 symbols shows up again, weakly, at 13 and 14. The strongest-echo rule never considered a lag beyond the strongest echo. The largest-lag rule does, and on a burst whose direct path had faded, an echo's sidelobe sometimes cleared the bar and read as a far path. So the estimator now cancels each echo's sidelobes the way it cancels the main path's, strongest echo first, before it looks for the largest lag.&lt;/p&gt;

&lt;p&gt;Cancelling takes the sidelobe's share off a lag, not the lag itself, so a real path sitting exactly where an echo's sidelobe falls still counts. It has to clear the bar by the sidelobe's share, though. For a path 9 symbols behind a strong echo, that means about 1 dB more than the bar, and a path closer to the bar than that is lost behind the echo. That is a limit of the method, and a test records it as one.&lt;/p&gt;

&lt;p&gt;The tests are worth a look for how they are built. Each three-path case also checks the lag profile, to prove the channel actually separates the rules it is meant to separate: that the 5-symbol echo really is the stronger lag, and that the old code really did read the sidelobe at lag 14 at 7.4 percent of the echo. A test separates two rules only if its channel makes them give different answers. The cases are in &lt;code&gt;tests/delay_spread_tests.cpp&lt;/code&gt;, from "A weaker echo at 12 symbols behind a strong one at 5 is the delay spread, not the strongest echo" to "A real path nine symbols behind the echo is the cancellation's limit".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. How far behind the anchor
   lag power behind the anchor, as a fraction of the strongest (lags 0 to 15; . under 0.05):
   fp1-echo-group  1.00    .    .    .    .    . 0.06    .    .    .    . 0.05 0.63    .    .    .
                   delay spread 12 symbols, over 8 preamble segments
   fp2-window      1.00 0.06    .    . 0.08    .    .    .    .    .    .    .    .    .    .    .
                   delay spread 0 symbols, over 21 preamble segments
   clean           1.00    .    .    .    .    .    .    .    .    .    .    .    .    .    .    .
                   delay spread 0 symbols, over 23 preamble segments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row is one burst's profile: the power at each lag behind the anchor, after the subtractions, as a fraction of the strongest. On fp1, the 300 bps recording through the 5 ms channel, the echo stands at lag 12 with 0.63 of the main path's power, and that is the delay spread. What is left at lags 6 and 11 is a few percent, well under the bar of an eighth. The clean burst has one path and reads 0. fp2, through the 2 ms channel, also reads 0, and the next paragraph is why.&lt;/p&gt;

&lt;p&gt;The estimate only looks behind the anchor. On a burst anchored on the echo it reads 0, because no path follows the echo; the direct path is ahead of it, where this search does not look. That is one more reason the anchor goes on the first path.&lt;/p&gt;

&lt;h2&gt;
  
  
  A window sized from the measurement
&lt;/h2&gt;

&lt;p&gt;The equalizer is a window of taps on the received samples, half a symbol apart, plus taps on symbols it has already decided. &lt;code&gt;select_body_receiver_geometry()&lt;/code&gt; picks one of two shapes from the measured spread:&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="k"&gt;constexpr&lt;/span&gt; &lt;span class="n"&gt;BodyReceiverGeometry&lt;/span&gt; &lt;span class="nf"&gt;select_body_receiver_geometry&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;delay_spread_symbols&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;delay_spread_symbols&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body_receiver_default_delay_symbols&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="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;32U&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;14U&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="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body_receiver_feedforward_taps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_receiver_feedforward_taps&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_receiver_feedback_taps&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 default holds a 2 ms channel: 24 taps with the reference tap at 12, so 6 symbols ahead of it and 5½ behind, and 8 taps on past decisions. A 2 ms echo is 4.8 symbols late, inside that reach. A spread longer than 6 symbols takes the long shape: 32 taps with the reference tap at 6, so 3 symbols ahead and 12½ behind, and 14 taps on past decisions, enough to reach a 5 ms echo at 12 symbols. Thirty-two feed-forward taps is as large as &lt;code&gt;ProbeDfe&lt;/code&gt; goes.&lt;/p&gt;

&lt;p&gt;The long shape is lopsided on purpose. With the anchor on the first path, the energy that matters arrives after it, so the window spends its taps behind the reference and few ahead. The feedback taps do the rest: an echo of a symbol already decided can be rebuilt from that decision and subtracted. The choice comes from the measurement, never from the mode: a 300 bps burst on a short channel gets the short window. The estimator can only answer 0 or 3 to 15, so in practice 0 to 6 means the default and 7 to 15 the long shape.&lt;/p&gt;

&lt;p&gt;One rate is different. At 75 bps the receiver keeps the window but sets the feedback taps to zero, because it has nothing reliable to feed back. Feedback cancels the trailing echoes of symbols already decided, one symbol at a time, so it needs a sound decision for each symbol as it passes. At 75 bps each pair of bits is spread over a 32-symbol sequence, and the receiver only knows which sequence was sent after it has correlated all 32. A guess at one symbol on its own is made about 15 dB below the group's signal level, the gain of spreading over 32, and at the signal levels 75 bps exists for it would be wrong more often than right. Fed back, those guesses would add errors instead of removing echoes. So the feed-forward taps alone shape the channel, and the group correlation does the deciding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2. A window sized from the measurement
   fp1-echo-group  spread 12 -&amp;gt; 32 taps, reference tap 6: 3.0 symbols ahead, 12.5 behind, 14 feedback; trained again on it
   fp2-window      spread  0 -&amp;gt; 24 taps, reference tap 12: 6.0 symbols ahead, 5.5 behind, 8 feedback
   clean           spread  0 -&amp;gt; 24 taps, reference tap 12: 6.0 symbols ahead, 5.5 behind, 8 feedback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fp1 gets the long window and is trained a second time on it; the other two keep the default. The demo prints the window's reach the way the receiver uses it: with the reference tap at 6 of 32 half-symbol taps, the window covers 3 symbols ahead of the anchor and 12.5 behind, and the 14 feedback taps reach past the 12-symbol echo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Training on the preamble
&lt;/h2&gt;

&lt;p&gt;Once the countdown is read, the whole preamble is known: every symbol the transmitter sent before the body. &lt;code&gt;train_preamble()&lt;/code&gt; uses it to train the equalizer before the first data symbol arrives, in three steps.&lt;/p&gt;

&lt;p&gt;First, a one-tap guess. Over the 288-symbol prefix it measures the channel as a single complex gain, the received symbols against the known ones, and puts that gain on the reference tap. Every other tap starts at zero.&lt;/p&gt;

&lt;p&gt;Second, practice. It runs the whole known preamble through the equalizer three times, adapting every tap against the true symbol each time, while a carrier loop takes out the frequency error acquisition left. Each pass starts over at the beginning of the preamble, with the carrier loop and the past decisions cleared, but the taps keep what the last pass taught them. The 75 bps short-interleave preamble is shorter, so it gets eight passes. The equalizer's step size depends on the rate: 0.40 for 600 bps and up, which must converge quickly, and 0.10 for 75, 150 and 300 bps, whose 5 ms, 5 Hz channel punishes a step that large with gradient noise. The score is the mean squared error over the last 96 symbols of the last pass.&lt;/p&gt;

&lt;p&gt;Third, a check on the frequency. Acquisition's frequency estimate is good on a clean channel and can be biased on a fading one. So the score decides. If the first training's error is already at the floor (one percent of the signal power), training is done. If not, the receiver makes up to two more guesses: a fresh estimate from the whole preamble, kept only if it differs from acquisition's by 1.5 to 30 Hz, and a 21-point search across ±30 Hz in 3 Hz steps that fits a channel to the preamble's last 48 symbols at each frequency, where the end points are never allowed to win. Each guess gets a complete training of its own, and the lowest error wins; a tie goes to acquisition. A new estimate never replaces the old one without that measurement, because on a fading channel the estimates themselves fade. Step 3 of the demo shows it: on fp1 the whole-preamble estimate comes out at +21 Hz, where acquisition said +2.55 and the search found -0.51. Taken on trust, it would have trained the equalizer twenty hertz off; scored, it has the worst error of the three and is thrown out.&lt;/p&gt;

&lt;p&gt;Training is also why the order above is not quite the order the code runs. The spread estimate correlates blocks of 32 symbols coherently, so it needs a good frequency, and the good frequency comes out of training. Both receivers therefore train on the default window first, measure the spread with the frequency that training chose, and train again, from scratch, only if the measurement asks for the long window. A 2 ms burst trains once; a 5 ms burst trains twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3. Training on the preamble
   fp1-echo-group: acquisition's frequency +2.55 Hz
   its error stayed above the floor, so the receiver trained every distinct guess:
   acquisition's estimate       +2.55 Hz   error on the preamble's tail 0.623
   a new guess                 +21.13 Hz   error on the preamble's tail 0.741
   a new guess                  -0.51 Hz   error on the preamble's tail 0.622   &amp;lt;- kept
   (the new guesses: the whole-preamble estimate and the channel-fit search, each when it differs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On fp1, the error after training on acquisition's frequency stayed far above the floor, so the receiver trained two more guesses. The whole-preamble estimate, +21 Hz, did worse. The search's -0.51 Hz did better by a thousandth and was kept; had it only tied, acquisition's estimate would have stood. On the clean burst the first training reaches the floor and the race never starts (the walkthrough tests check both).&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the channel
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProbeDfe&lt;/code&gt; is the adaptive equalizer. Each output is the window's taps applied to the received samples, minus the feedback taps applied to the symbols already decided:&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="n"&gt;ProbeDfe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;output&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;feedforward_window&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="n"&gt;IQSample&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;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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedforwardTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;mFeedforwardCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;feedforward_window&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedbackTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;mFeedbackCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mDecisionHistory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&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;value&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 body comes in frames: a run of data symbols, then a probe of known ones. At 150 to 1200 bps a frame is 20 data symbols and a 20-symbol probe; at 2400 bps it is 32 and 16. (75 bps has no probes at all; the last section explained why it runs on the feed-forward taps alone.) &lt;code&gt;equalize_frame()&lt;/code&gt; walks one frame at a time, and it handles the two kinds of symbol differently.&lt;/p&gt;

&lt;p&gt;On a probe the right answer is known, so the error is exact: the known symbol minus the output. The taps move a small step against it. The update is normalized LMS: the step is divided by the energy in the window and in the feedback register, so a loud signal and a quiet one adapt at the same rate. Every probe symbol trains, whatever its error.&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="n"&gt;ProbeDfe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;train_nlms&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;feedforward_window&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;desired&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;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedforward_window&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;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;energy&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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedforwardTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;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;feedforward_window&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedbackTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;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;mDecisionHistory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&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;normalized_step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mStepSize&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;energy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0e-6F&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;retention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0F&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mLeakage&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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedforwardTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mFeedforwardCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mFeedforwardCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;normalized_step&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;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;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feedforward_window&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&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;tap&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;tap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;mFeedbackTaps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mFeedbackCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mFeedbackCoefficients&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;normalized_step&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;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;conj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mDecisionHistory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;commit_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desired&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;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On data the receiver has no right answer, only its own decision: the output rounded to the nearest 8-PSK point. It trains on that decision only when the decision passes two tests. The output must carry real energy, at least a quarter of a symbol's, and it must sit within one neighbour's distance of the point it was rounded to. A decision that fails either test does not move the taps or steer the carrier loop; the carrier simply coasts for that symbol. It still goes into the feedback register, though, because the next symbols' echoes of it have to be cancelled with something, and the decision is the best guess there is.&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;process_data_symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&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;make_equalizer_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;received_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;samples_per_symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mCarrierTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phase_radians&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;mCarrierTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frequency_radians_per_symbol&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&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;IQSample&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;window&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feedforward_taps&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;decision&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;psk8_hard_demapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="c1"&gt;// Decision-directed adaptation between probes, gated on decision&lt;/span&gt;
        &lt;span class="c1"&gt;// reliability: the output must carry real energy and its residual must&lt;/span&gt;
        &lt;span class="c1"&gt;// stay inside the distance to the nearest wrong 8-PSK decision, so a&lt;/span&gt;
        &lt;span class="c1"&gt;// noise-only or likely-wrong decision can neither train the taps nor&lt;/span&gt;
        &lt;span class="c1"&gt;// steer the carrier loop. The rejected path still commits the decision&lt;/span&gt;
        &lt;span class="c1"&gt;// to the feedback register and advances the carrier phase open-loop.&lt;/span&gt;
        &lt;span class="k"&gt;constexpr&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;squared_adjacent_decision_distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.58578644F&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;decision_reliable&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;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25F&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;decision&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;squared_adjacent_decision_distance&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;decision_reliable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adapt&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;IQSample&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;window&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feedforward_taps&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;decision&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;ignore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mCarrierTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&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.0F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mCarrierTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phase_radians&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;decision&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="n"&gt;mEqualizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;mCarrierTracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;equalized_symbols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;received_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;output&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;One limit: between probes the taps move only on the receiver's own decisions, so a fade that makes a few decisions wrong can move the taps the wrong way, and the gate catches only the worst of them. The next probe measures the damage. If a probe's mean error comes back above half the signal power and more than twice the block's usual level, or above 0.9 of the signal power whatever the usual level, the equalizer has lost the channel. It clears its feedback register and replays that probe four more times before the next frame, so it starts the frame from a filter that fits the channel again.&lt;/p&gt;

&lt;p&gt;One more step happens on the way out, and Part 7 depends on it. Each probe measures how noisy the equalizer's output is at that moment. The receiver scales every data symbol by the inverse of that noise, interpolated between the probe before the frame and the probe after it, so a symbol sliding into a fade comes out smaller than one in a strong stretch. The scale does not change a hard decision. It changes the soft decoder's input: a smaller symbol reads as a less certain one.&lt;/p&gt;

&lt;p&gt;The equalizer adapts with normalized LMS. &lt;code&gt;ProbeDfe&lt;/code&gt; also carries a second method, recursive least squares, which converges in about twice as many symbols as it has taps, where normalized LMS takes many tens. The receiver never selects it. Its state is still compiled into every &lt;code&gt;ProbeDfe&lt;/code&gt;, about 18 KiB for the matrix alone, which an embedded build pays for without using; gating it out the way the CIR engine is gated is a change for a later version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4. Following the channel
   adaptive on fp1-echo-group: 951 of 5760 data symbols decided wrong before the decoder (16.5%); 36 of 288 frames without one
   the first 24 frames (wrong data symbols / probe error):
    1/0.73  4/1.12  2/0.72  2/0.69  3/0.82  3/0.69  3/0.63  3/0.57
    1/0.83  6/0.72  4/0.79  3/0.71  3/0.50  4/0.53  1/0.79  2/0.74
    3/0.59  1/0.33  1/0.38  0/0.31  0/0.47  1/0.80  6/0.88 10/1.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each pair is one frame: how many of its 20 data symbols the equalizer decided wrong, and the mean squared error on the probe that followed. A wrong decision here means a different point of the mode's own alphabet was nearer; at 300 bps that alphabet is two points, so the demo does not count a near miss among eight. Across the whole body the adaptive equalizer decides about one data symbol in six wrong. The probes tend to rise with the errors around them: the frame with 10 wrong decisions has a probe error of 1.10. These are decisions before the decoder; Part 7's decoder works from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the channel
&lt;/h2&gt;

&lt;p&gt;The other way starts from a count. On the long window the adaptive equalizer has 46 taps to set, 32 feed-forward and 14 feedback, and it learns them a step at a time. The channel itself is shorter. The CIR-MMSE engine models it as 32 taps at half-symbol spacing on the long window, 20 on the default one, and splits them into two sets, one for each half-symbol phase, so each set is an ordinary symbol-spaced channel of 16 or 10 taps. The split also keeps the arithmetic simple: after the receive filter, the noise in each phase is uncorrelated from one symbol to the next, so each set is a plain least-squares problem.&lt;/p&gt;

&lt;p&gt;A 20-symbol probe gives each set 18 or 19 equations. That is plenty for 10 unknowns and only just enough for 16, so the estimate leans on two more sources. The equations reach back into the data symbols just before the probe, using the receiver's own decisions there, and each equation counts half as much for every doubtful decision it uses. The solve is also pulled toward the previous frame's channel, more strongly when the noise is high (ridge regression, with the old estimate as the prior).&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;// cir_polyphase_solve(): the normal equations, weighted by the rows' reliability,&lt;/span&gt;
&lt;span class="c1"&gt;// pulled toward the prior (lambda) and kept invertible (epsilon).&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;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;dimension&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;rhs&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;lambda&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prior&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="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;row&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;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;row&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;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&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;regressor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regressors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dimension&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="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weight&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;regressor&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="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;j&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="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;matrix&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;*&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;regressor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;rhs&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="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&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;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;0U&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;dimension&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;matrix&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;*&lt;/span&gt; &lt;span class="n"&gt;dimension&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="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="n"&gt;lambda&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;epsilon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0F&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;j&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;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;matrix&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;*&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;j&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;matrix&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;dimension&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="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;cholesky_solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0F&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="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;internal_error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CIR polyphase system lost positive definiteness"&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;From the estimated channel the receiver solves for the filter instead of adapting one: the feed-forward and feedback taps that give the least mean squared error for that channel. The feedback taps cancel the echoes of the last few decided symbols exactly. Everything else, including any echo longer than the feedback span, is treated as interference, together with the noise as the receive filter shapes it. That is one small linear system per probe, solved by Cholesky factorization. It needs a small constant added to its diagonal: about a third of the half-symbol band carries neither signal nor modelled noise, so without that constant the system is nearly singular and the taps from one probe to the next disagree wildly.&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;// cir_mmse_dfe_solve(), one attempt of its retry ladder:&lt;/span&gt;
        &lt;span class="c1"&gt;// Interference: every lag the feedback register cannot cancel. The&lt;/span&gt;
        &lt;span class="c1"&gt;// cursor lag stays (MMSE); lags 1..feedback_taps are excluded because&lt;/span&gt;
        &lt;span class="c1"&gt;// the feedback taps cancel them exactly; the postcursor tail beyond&lt;/span&gt;
        &lt;span class="c1"&gt;// the register (present when the echo outruns the feedback span)&lt;/span&gt;
        &lt;span class="c1"&gt;// remains as interference.&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;auto&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lag_minimum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;lag_maximum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&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;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;ptrdiff_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;geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feedback_taps&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;continue&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;row&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;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;row&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;column_row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;channel_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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;column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;column_row&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;channel_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column&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;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;trace&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;row&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;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;row&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;column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;]&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="n"&gt;noise_variance_effective&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;raised_cosine_autocorrelation_half_lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;0.0F&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;trace&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row&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="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;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jitter_scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;trace&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="kt"&gt;float&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;taps&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;row&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;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&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="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0F&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;column&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;column&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;column&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;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;row&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;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;row&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;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;rhs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&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_column&lt;/span&gt;&lt;span class="p"&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;row&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;pivot_floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pivot_floor_fraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;trace&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="kt"&gt;float&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;taps&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;cholesky_solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rhs&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="n"&gt;pivot_floor&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 feed-forward taps are the solution, conjugated; the feedback taps follow from them&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between two probes the receiver interpolates the taps linearly, so the probe before a frame and the probe after it both shape every symbol in it. Linear interpolation cannot follow everything, though. A fade can turn the channel's phase sharply inside one frame, so the engine also runs a small, leaky adaptive correction on top of the interpolated taps, the same normalized LMS as &lt;code&gt;ProbeDfe&lt;/code&gt;, gated the same way, with a leak that drains any bad update within about 20 symbols. So the engine that measures the channel also follows it a little, and the measurement resets the base at every probe.&lt;/p&gt;

&lt;p&gt;Two more pieces deal with the decisions going wrong. Each frame solves two candidate channels: one from all the equations, decisions included, and one from the equations that use probe symbols only. The first tracks better while the decisions are right; once they go wrong, the least squares can fit the wrong decisions consistently and the estimate drifts away with them. The second has no decisions in it, so it can only lag. Both run the frame, and the one with the smaller error on the probe wins. If both fail, the frame is an outage. The receiver then collects probe-only equations across consecutive outage frames until there are enough of them, and solves again with no prior at all.&lt;/p&gt;

&lt;p&gt;There is no per-symbol carrier loop here. Each frame is derotated by a straight phase ramp, and the frequency is nudged once per frame from how far the channel estimate rotated since the last probe. The engine also picks its own starting point: at the end of the preamble it searches the frequency and the channel's alignment around the anchor together and keeps the pair that fits best, so it can start the body on a slightly different frequency than the adaptive equalizer's training chose. That is deliberate. The adaptive equalizer tracks the frequency symbol by symbol and can live with a small error in its starting point; a fixed frequency error here rotates each probe's channel estimate against the last one, and interpolating between two rotated estimates gives taps that fit neither.&lt;/p&gt;

&lt;p&gt;Two limits. The channel model reaches only a few symbols ahead of the anchor, so CIR-MMSE needs the anchor on the first path. In the measurements for this article, with the anchor left on the echo of a 2 ms channel, it lost every one of those bursts. And 75 bps has no probes, so at that rate the receiver uses the adaptive equalizer whichever engine was asked for.&lt;/p&gt;

&lt;p&gt;One difference is not about the models. Both engines scale their outputs by the measured noise for the soft decoder, but the adaptive equalizer interpolates that weight symbol by symbol across the frame, while CIR-MMSE uses one weight for the whole frame. Any comparison after the decoder includes that difference too.&lt;/p&gt;

&lt;p&gt;The cost is memory and arithmetic: about 14 KiB of working state per receiver, and two least-squares solves and two filter solves per probe. Host builds compile it in. Embedded builds leave it out with one build option, so the adaptive equalizer is the only one there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5. Measuring the channel
   CIR-MMSE on fp1-echo-group: 1286 of 5760 data symbols decided wrong before the decoder (22.3%); 53 of 288 frames without one
   the first 24 frames (wrong data symbols / probe error):
    0/0.34  0/0.61  2/0.42  1/0.51  1/0.44  1/0.38  1/0.35  0/0.37
    0/0.65  0/0.49  0/0.37  0/0.38  0/0.26  0/0.20  0/0.30  0/0.39
    0/0.27  0/0.23  1/0.19  0/0.29  0/0.48  3/0.70  5/0.71  7/1.35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same body, CIR-MMSE starts out cleaner: most of its first 20 frames have no wrong decision at all. Over the whole body it does worse, 22.3% against 16.5%, although it has more frames without a single error, 53 against 36. Its errors come in bunches.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the channel moves
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6. When the channel moves
   fp1-echo-group: where each engine trails the other most (wrong data symbols / probe error)
   block frame   adaptive      CIR-MMSE
   CIR-MMSE behind:
       0    96    0 / 0.40     10 / 1.06
       0    97    0 / 0.29     10 / 1.50
       0   127    0 / 0.61     13 / 1.32
   adaptive behind:
       0   117    7 / 0.87      1 / 0.74
       0   174    6 / 0.41      0 / 0.23
       0   212    8 / 0.69      1 / 0.56
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bunches are fades. In frames 96, 97 and 127, CIR-MMSE decides 10 to 13 of 20 symbols wrong and its probe error is above 1, more than the signal's own power: both of its channel estimates failed the probe, the frame was an outage, and the engine had to collect probe-only equations over the following frames before it could solve again. The adaptive equalizer went through the same frames with no wrong decision, its taps moving a little at every symbol. The other three rows run the other way. In frames 117, 174 and 212 the adaptive equalizer fell behind for a few symbols, and CIR-MMSE, which solves afresh from each probe, did not.&lt;/p&gt;

&lt;p&gt;Which of those patterns wins depends on the channel and the rate, and that takes more than one recording to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it measured
&lt;/h2&gt;

&lt;p&gt;I measured the two equalizers on the six long-interleave rows of Table 10.1, each through pathsim on its row's channel at its row's signal-to-noise ratio, 500 bursts a row. Every burst was rendered once and then decoded by every arm of the test, so each comparison is between two receivers on the same bursts, not on two runs of the channel's luck.&lt;/p&gt;

&lt;p&gt;All of these numbers come from the host's whole-burst receiver, the one the modem's test tools drive. The streaming receiver, the path an embedded build runs, is Part 8's subject, and I measure it there.&lt;/p&gt;

&lt;p&gt;Each equalizer ran three ways. Alone means the code in this article: equalize, demap, decode, nothing fed back. With the decoder means the receiver also takes each decoded block, encodes it again, and re-equalizes the block trained on those symbols; that pass needs the decoder, so it belongs to Part 7, but it shows how much of the gap better decisions close. The turbo receiver is Part 7's whole-burst decoder, for scale.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row&lt;/th&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;SNR&lt;/th&gt;
&lt;th&gt;Adaptive alone&lt;/th&gt;
&lt;th&gt;CIR-MMSE alone&lt;/th&gt;
&lt;th&gt;Adaptive with the decoder&lt;/th&gt;
&lt;th&gt;CIR-MMSE with the decoder&lt;/th&gt;
&lt;th&gt;Turbo receiver&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2400 bps&lt;/td&gt;
&lt;td&gt;2 ms, 1 Hz&lt;/td&gt;
&lt;td&gt;18 dB&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;454&lt;/td&gt;
&lt;td&gt;496&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1200 bps&lt;/td&gt;
&lt;td&gt;2 ms, 1 Hz&lt;/td&gt;
&lt;td&gt;11 dB&lt;/td&gt;
&lt;td&gt;471&lt;/td&gt;
&lt;td&gt;470&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;600 bps&lt;/td&gt;
&lt;td&gt;2 ms, 1 Hz&lt;/td&gt;
&lt;td&gt;7 dB&lt;/td&gt;
&lt;td&gt;488&lt;/td&gt;
&lt;td&gt;399&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;483&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 bps&lt;/td&gt;
&lt;td&gt;5 ms, 5 Hz&lt;/td&gt;
&lt;td&gt;7 dB&lt;/td&gt;
&lt;td&gt;346&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;455&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150 bps&lt;/td&gt;
&lt;td&gt;5 ms, 5 Hz&lt;/td&gt;
&lt;td&gt;5 dB&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;293&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;467&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75 bps&lt;/td&gt;
&lt;td&gt;5 ms, 5 Hz&lt;/td&gt;
&lt;td&gt;2 dB&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Bursts of 500 decoded exactly. At 75 bps every arm runs the adaptive equalizer, since that rate has no probes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read alone, the two equalizers trade places with the data rate. At 2400 bps measuring the channel wins: on the bursts where the two disagreed, CIR-MMSE decoded 157 that the adaptive equalizer did not, and the adaptive equalizer 13 that CIR-MMSE did not. At 1200 bps they tie, 17 against 18. Below that, following the channel wins: 90 against 1 at 600 bps, 217 against 28 at 300 bps, and 206 against none at 150 bps. A sign test on the bursts where the two disagree puts every one of those rows far beyond chance except 1200 bps, which is a tie.&lt;/p&gt;

&lt;p&gt;In the overview I wrote that measuring the channel and solving the filter is the stronger move. On these channels that holds at 2400 bps and not below it, and the 5 ms, 5 Hz rows, where I expected the model to win, are where it loses by the most.&lt;/p&gt;

&lt;p&gt;I have not measured why, and I will not guess here. One difference is not about the models at all, though. Both engines scale their outputs by the measured noise before the decoder, but the adaptive equalizer interpolates that weight symbol by symbol across a frame, and CIR-MMSE uses one weight per frame. A comparison after the decoder includes that difference along with the equalizers themselves, and on the 5 Hz rows, where the noise changes fastest inside a frame, it can matter most.&lt;/p&gt;

&lt;p&gt;With the decoder's help, the order changes again. The adaptive equalizer then decodes all but 4 bursts across the table, every one of the 4 at 2400 bps, while CIR-MMSE still loses bursts at 600, 300 and 150 bps. The turbo receiver decodes everything but one burst at 75 bps. Part 7 builds both.&lt;/p&gt;

&lt;p&gt;You can run a smaller version of this comparison yourself. &lt;code&gt;equalizer_compare&lt;/code&gt; sends bursts of random bytes through the test bench's two-path channel on each long-interleave row, one seed per burst, and equalizes each burst twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build/equalizer_compare
&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;== The two equalizers on the same bursts: 20 a row, the bench's two-path channel ==
   data decisions wrong before the decoder, in the mode's own alphabet

   row     channel      SNR   acquired   adaptive   CIR-MMSE   adaptive better   CIR-MMSE better   same
   2400L   2 ms 1 Hz    18 dB    20 of 20    30.71%     20.22%                 2                18      0
   1200L   2 ms 1 Hz    11 dB    20 of 20    13.90%     14.31%                 3                17      0
   600L    2 ms 1 Hz     7 dB    20 of 20     5.65%      8.82%                20                 0      0
   300L    5 ms 5 Hz     7 dB    20 of 20    19.53%     22.90%                19                 1      0
   150L    5 ms 5 Hz     5 dB    20 of 20    21.41%     26.95%                20                 0      0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its numbers are decisions before the decoder, through an engineering model of the channel rather than the calibrated simulator, so they are not the table above. The shape is the same: CIR-MMSE decides better at 2400 bps, the two are close at 1200 bps (CIR-MMSE wins most bursts there but loses a few badly), and the adaptive equalizer decides better at 600 bps and below. Whether each burst's message comes out needs the decoder; Part 7 grows this program into that measurement.&lt;/p&gt;

&lt;p&gt;The anchor matters more to one of them. CIR-MMSE cannot describe a path that arrives more than a few symbols before its anchor. On the 2400 and 1200 bps rows, 226 of the 500 bursts were acquired on the echo; with the anchor left there, CIR-MMSE lost all 226 on both rows, even with the decoder's help. The adaptive equalizer, anchored on the echo of a 2 ms channel, decodes anyway: the direct path is 4.8 symbols ahead, inside its window. Moving its anchor onto the direct path did not help it; alone at 2400 bps it then decoded 252 bursts instead of 310. That is Part 5's window rule seen from the other side: the adaptive equalizer's anchor moves only for a path 7 or more symbols early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing, part three: the genie
&lt;/h2&gt;

&lt;p&gt;How good is good? A count of decoded bursts does not say whether an equalizer is close to what the channel allows or far from it. A genie answers that. It is the same receiver, given the true transmitted symbols to train on at every symbol instead of its own decisions. No real receiver can do that, since the transmitted symbols are what it is trying to find, but a test can, because the test sent them. The genie sets a bound: the gap between it and the real equalizer is how much better decisions would be worth, and a burst the genie still loses was lost to the channel, not to the receiver.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row&lt;/th&gt;
&lt;th&gt;Adaptive alone&lt;/th&gt;
&lt;th&gt;Adaptive genie&lt;/th&gt;
&lt;th&gt;CIR-MMSE alone&lt;/th&gt;
&lt;th&gt;CIR-MMSE genie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2400 bps&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;454&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1200 bps&lt;/td&gt;
&lt;td&gt;471&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;470&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;600 bps&lt;/td&gt;
&lt;td&gt;488&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;399&lt;/td&gt;
&lt;td&gt;486&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 bps&lt;/td&gt;
&lt;td&gt;346&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;td&gt;493&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150 bps&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;293&lt;/td&gt;
&lt;td&gt;490&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75 bps&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Bursts of 500 decoded exactly, on the same bursts as the table above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Neither equalizer alone reaches its genie. The adaptive equalizer's gap is 190 bursts at 2400 bps and 152 at 300 bps; CIR-MMSE's is 336 at 300 bps and 197 at 150 bps. Almost all of those bursts fail because of the equalizer's own decisions, not because the channel destroyed them. The 2 bursts at 300 bps and the 1 at 75 bps that the adaptive genie also loses are the channel's.&lt;/p&gt;

&lt;p&gt;The two genies also differ. With true symbols the adaptive equalizer decodes every burst on the first five rows except those 2 at 300 bps. CIR-MMSE with true symbols still loses 14 bursts at 600 bps, 7 at 300 bps and 10 at 150 bps that the adaptive genie decodes (and decodes 2 at 300 bps that the adaptive genie loses). On those bursts better decisions alone would not have rescued it; the shortfall is in the model or the way it tracks the channel.&lt;/p&gt;

&lt;p&gt;Better decisions are exactly what the decoder supplies, and the first table shows it: with the decoder's help, the adaptive equalizer comes within 4 bursts of its genie, all at 2400 bps. Part 7 builds that decoder.&lt;/p&gt;

&lt;p&gt;One measure pointed the wrong way. Alone at 300 bps, CIR-MMSE made slightly fewer symbol errors before the decoder than the adaptive equalizer, and decoded fewer than half as many bursts. A symbol error rate counts every wrong symbol the same; the decoder does not, and what reaches the user is the message. So the tables count bursts decoded exactly, and the bit errors behind them, after the decoder. The campaign's tool counted those errors the blunt way, against all eight 8-PSK points and over every symbol, probes included. At 300 bps that inflates both engines' counts alike, so the comparison stands, but it is also why the demo counts a decision wrong only against the points the mode can send.&lt;/p&gt;

&lt;p&gt;About the test itself: every arm decoded the same bursts, so a difference is a difference in the receivers; the disagreements are counted burst by burst and tested for chance; and every result names the revision, the seeds and the command that produced it. The campaign itself ran on my private test tools, but &lt;code&gt;equalizer_compare&lt;/code&gt; keeps the same design in the public repository: the same bursts for both engines, one seed each, counted pair by pair, so you can run that part of the test yourself.&lt;/p&gt;

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

&lt;p&gt;Three suites cover this article's code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/delay_spread_tests.cpp&lt;/code&gt; builds its channels from the transmitter and checks the estimate and the window it picks: one clean path, a 2 ms and a 5 ms echo, the weaker far echo behind a strong near one, an echo's own sidelobes, a real path 8 symbols behind an echo that survives the cancellation, and one 9 behind that shows the cancellation's limit. Its three-path cases also check the lag profile, so each proves its channel tells the rules apart.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/equalizer_tests.cpp&lt;/code&gt; checks the pieces one at a time on synthesized signals: the one-tap channel estimate and the plain LMS equalizer, the decision feedback cancelling a one-symbol echo, the CIR solvers recovering a known channel and the MMSE-DFE taps they give, training on a two-path channel, supervised re-equalization, the receiver's lifecycle (configured, trained, rearmed, reset), its probe residual through a gap in the signal, the carrier loop's clamp, the frequency race skipped on a clean channel and run on a poor one, resumable training giving the batch training's receiver bit for bit, and which engines a build can select.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/equalizer_walkthrough_tests.cpp&lt;/code&gt; runs the walkthrough's steps and checks what the article says about them: on the clean burst a spread of 0, the default window, no frequency race, and both engines deciding every data symbol right; on the recordings, fp1's spread of 12 and its second training on the long window, fp2's echo anchor reading 0, the race keeping a better guess than acquisition's, the adaptive equalizer ahead of CIR-MMSE through fp1's body, and frames where each engine trails the other. A build without the CIR-MMSE engine skips the cases that need it.&lt;/p&gt;

&lt;p&gt;The first two need no recordings and run in the quick suite. The walkthrough's recording cases carry the &lt;code&gt;first-path&lt;/code&gt; label with Part 5's, so the article suite runs them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ctest &lt;span class="nt"&gt;--test-dir&lt;/span&gt; build &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"delay_spread|equalizer"&lt;/span&gt; &lt;span class="nt"&gt;--output-on-failure&lt;/span&gt;
ctest &lt;span class="nt"&gt;--test-dir&lt;/span&gt; build &lt;span class="nt"&gt;-L&lt;/span&gt; first-path &lt;span class="nt"&gt;--output-on-failure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The runner gains a Part 6 line: the delay spread it measured, the window it chose, the frequency training kept, and what each engine made of the body. On a recording it cannot count wrong decisions, because it does not know what was sent, so it reports each engine's mean probe error instead:&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="nt"&gt;--wav&lt;/span&gt; test-data/first-path-v1/fp1-echo-group.wav
&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 6  the equalizer: delay spread 12 symbols -&amp;gt; the long window, 32 taps, reference tap 6, 14 feedback; trained again on it
           training: acquisition's +2.6 Hz stayed above the floor, so the receiver raced 3 guesses and kept -0.5 Hz
           adaptive: mean probe error 0.682 over 1 block; counting wrong decisions needs the transmitted symbols
           CIR-MMSE: mean probe error 0.744 over 1 block; counting wrong decisions needs the transmitted symbols
== next: soft demapping and the decoder (Part 7)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a message of your own the runner builds the burst itself, so it knows every symbol and counts the decisions:&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 6  the equalizer: delay spread 0 symbols -&amp;gt; the default window, 24 taps, reference tap 12, 8 feedback
           training: acquisition's +37.7 Hz stayed above the floor, but no other guess differed from it, so it kept +37.7 Hz
           adaptive: 0 of 5760 data symbols decided wrong before the decoder (0.0%)
           CIR-MMSE: 0 of 5760 data symbols decided wrong before the decoder (0.0%)
== next: soft demapping and the decoder (Part 7)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It still stops before the decoder; that is next week.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;In MIL-STD-188-110B 5.3.2.2.2, find the frame of unknown data symbols and known probe symbols for each data rate, and check them against &lt;code&gt;body_block_plan()&lt;/code&gt; in &lt;code&gt;body_waveform.hpp&lt;/code&gt;. What fraction of the symbols is known at 2400 bps, and at 600 bps?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;estimate_body_preamble_delay_spread()&lt;/code&gt; never counts lags 0 to 2 as an echo. A 1 ms echo arrives 2.4 symbols late: over which lags does its power fall, and what could the estimate report for it?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cancel_echo_sidelobes()&lt;/code&gt; removes the strongest echo's sidelobes first, not the nearest echo's. What could go wrong if a weaker echo were handled before a stronger one?&lt;/li&gt;
&lt;li&gt;Work out the long window's reach from 32 taps half a symbol apart with the reference tap at 6. Why does &lt;code&gt;select_body_receiver_geometry()&lt;/code&gt; switch above 6 symbols rather than 5, when a 2 ms echo is 4.8 symbols late?&lt;/li&gt;
&lt;li&gt;The receiver trains on the default window before it measures the delay spread. How long is each coherent block in the estimate, and why does that make acquisition's frequency the wrong one to measure with?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inner_trainer_begin()&lt;/code&gt; seeds one tap from the prefix and then replays the preamble three times. Why does each replay clear the decision history and the carrier loop but keep the taps? Why does the 75 bps short preamble get eight passes?&lt;/li&gt;
&lt;li&gt;The frequency race keeps the whole-preamble estimate only if it differs from acquisition's by more than 0.004 and less than 0.08 radians per symbol. What are those limits in hertz at 2400 Bd, and why is there a lower one?&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;equalize_frame_impl()&lt;/code&gt;, a data decision trains the taps only if the output's power is at least 0.25 and its squared distance to the decision is at most 0.5858. Where does 0.5858 come from? Why does a rejected decision still go into the feedback register?&lt;/li&gt;
&lt;li&gt;The adaptive walk scales each data output by the inverse of the probe error around it. Why does that change no hard decision, and what does it give Part 7's soft decoder?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cir_polyphase_solve()&lt;/code&gt; works on two problems, one for each half-symbol phase. Using &lt;code&gt;raised_cosine_autocorrelation_half_lag()&lt;/code&gt;, show why the noise is uncorrelated from symbol to symbol within one phase.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cir_mmse_dfe_solve()&lt;/code&gt; adds 2% of the mean diagonal before it solves. What happens to consecutive frames' taps without it, and why does that matter to the interpolation between probes?&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;equalizer_compare --seeds 100&lt;/code&gt;. Does the 1200 bps row stay close? What does it mean when one engine decides better on most bursts but has nearly the same error rate overall?&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Next week: soft demapping, the decoder, and the turbo loop. The equalizer hands over symbols and its own decisions about them. The decoder works better with less: not a decision, but how sure the receiver is of each bit. Part 7 turns symbols into those probabilities, decodes them, and then feeds the decoder's answers back to the equalizer, which is the turbo equalizer Part 5 promised.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>dsp</category>
      <category>radio</category>
    </item>
    <item>
      <title>Before the assembler existed: bringing up a DSP with Excel</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:07:19 +0000</pubDate>
      <link>https://dev.to/kg7hf/before-the-assembler-existed-bringing-up-a-dsp-with-excel-976</link>
      <guid>https://dev.to/kg7hf/before-the-assembler-existed-bringing-up-a-dsp-with-excel-976</guid>
      <description>&lt;p&gt;Here is a story from DSP bring-up in the early 2000s.&lt;/p&gt;

&lt;p&gt;I used Excel when I was bringing up new digital signal processors that had their own instruction sets. The tools were still being developed. We did not want or need a full assembler for those first tests. We needed enough to do a few things: loop, jump, and toggle I/O pins controlled by memory-mapped registers.&lt;/p&gt;

&lt;p&gt;We were writing the debugger. The team down the hall was writing the assembler and disassemblers. In the building across town, another team was writing the Verilog for the DSP ASICs.&lt;/p&gt;

&lt;p&gt;The spreadsheet calculated instruction words from the fields in the instruction set. That let us prepare the small bring-up programs while the assembler was still being developed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A spreadsheet assembler for the PC
&lt;/h2&gt;

&lt;p&gt;I wanted to turn that experience into an exercise for engineers starting out today. I also wanted to revisit the executable format. I did a lot of x86 work roughly 30 years ago, and I needed a refresher on some of those details.&lt;/p&gt;

&lt;p&gt;I built this example with LibreOffice Calc. It produces a native Windows x86 executable. The workbook uses fixed instruction templates and an editable operand. It does not parse arbitrary assembly source or resolve labels.&lt;/p&gt;

&lt;p&gt;This version uses CSV and a simple script to convert hex values into bytes. That is close to what I remember doing during the DSP work. The original export may have used commas or spaces between values; the process was the same. This example uses x86 instructions in place of the original DSP instruction set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set the exit code
&lt;/h2&gt;

&lt;p&gt;The program calls Windows' &lt;code&gt;ExitProcess&lt;/code&gt; function with the value entered in cell B4. For an input of 42, Calc produces these bytes for &lt;code&gt;PUSH 42&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;68 2A 00 00 00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;68&lt;/code&gt; selects the instruction form. The following four bytes contain 42 in little-endian order.&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%2Ftpotv0zw3mgxmwm11cn7.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%2Ftpotv0zw3mgxmwm11cn7.png" alt="Calc Assembler sheet with B4 set to 42 and the decimal and hexadecimal instruction bytes" width="800" height="770"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Assembler sheet. B4 contains 42; the first instruction encodes as 68 2A 00 00 00. The capture was taken after Save As CSV, so the title reads image.csv. Use assembler.ods for further edits.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The next instruction calls &lt;code&gt;ExitProcess&lt;/code&gt; through the import address table. The program prints nothing. PowerShell reports its exit code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Image sheet contains
&lt;/h2&gt;

&lt;p&gt;The Image sheet contains the complete sequence of bytes written to the executable file. Each data row represents one byte. The instruction bytes link to the calculations on the Assembler sheet. The Windows headers and import records come from a fixed template.&lt;/p&gt;

&lt;p&gt;This template has 12 bytes of code in a 1,536-byte file. Headers, import records, and section padding account for the remaining space.&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%2Fjywmah7o47ttxa8bhqyq.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%2Fjywmah7o47ttxa8bhqyq.png" alt="Calc Image sheet showing the instruction bytes at file offsets 512 through 523" width="800" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The code starts at worksheet row 514, file offset 512. Column B is decimal; column C is hex. At row 515, the adjacent values 42 and 2A represent the same byte.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What PE headers do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PE means Portable Executable.&lt;/strong&gt; Its headers describe the target processor, entry point, section layout, and imported functions. Windows uses this information to load the program. The headers do not switch the CPU into protected mode.&lt;/p&gt;

&lt;p&gt;The file starts with an &lt;code&gt;MZ&lt;/code&gt; header containing a pointer to the &lt;code&gt;PE&lt;/code&gt; signature. The section table connects file offsets to relative virtual addresses. The import records identify functions such as &lt;code&gt;ExitProcess&lt;/code&gt;. Microsoft's &lt;a href="https://learn.microsoft.com/en-us/windows/win32/debug/pe-format" rel="noopener noreferrer"&gt;PE format reference&lt;/a&gt; documents the structures.&lt;/p&gt;

&lt;p&gt;In this template, the first instruction is at file offset &lt;code&gt;0x200&lt;/code&gt;. Its relative virtual address is &lt;code&gt;0x1000&lt;/code&gt;. With the fixed image base of &lt;code&gt;0x00400000&lt;/code&gt;, execution starts at virtual address &lt;code&gt;0x00401000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The file offset and loaded address are different. The headers describe that mapping. Renaming raw instruction bytes to &lt;code&gt;.exe&lt;/code&gt; would not supply it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Export and run it manually
&lt;/h2&gt;

&lt;p&gt;The first lesson covers each step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the ODS workbook and set the exit-code input.&lt;/li&gt;
&lt;li&gt;Inspect the formulas and calculated instruction bytes.&lt;/li&gt;
&lt;li&gt;Save the workbook, then export the Image sheet as UTF-8 CSV.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;hex-to-exe.ps1&lt;/code&gt; to convert the exported hex values into bytes.&lt;/li&gt;
&lt;li&gt;Run the executable and check &lt;code&gt;$LASTEXITCODE&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Calc performs the instruction encoding. The converter checks the CSV rows and writes the bytes. The text &lt;code&gt;2A&lt;/code&gt; becomes one byte with value &lt;code&gt;0x2A&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The transcripts use &lt;code&gt;C:\work\simple-spreadsheet-assembler&lt;/code&gt; as the example folder. Paths are shortened for readability; &lt;code&gt;export-&amp;lt;id&amp;gt;&lt;/code&gt; represents the build's unique export directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PS C:\work\simple-spreadsheet-assembler&amp;gt; .\hex-to-exe.ps1 -Csv .\image.csv -Output .\program.exe
Wrote 1536 bytes to C:\work\simple-spreadsheet-assembler\program.exe
PS C:\work\simple-spreadsheet-assembler&amp;gt; .\program.exe
PS C:\work\simple-spreadsheet-assembler&amp;gt; $LASTEXITCODE
42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Manual conversion and execution on Windows. The converter writes program.exe. The next command runs it. $LASTEXITCODE reports 42.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kg7hf/simple-spreadsheet-assembler/blob/main/LESSON-1.md" rel="noopener noreferrer"&gt;Lesson one: assemble a Windows program with LibreOffice Calc&lt;/a&gt; walks through the workbook, export options, and executable layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automate the export and conversion
&lt;/h2&gt;

&lt;p&gt;The second lesson introduces &lt;code&gt;build.ps1&lt;/code&gt;. It loads the saved workbook in headless LibreOffice, recalculates the formulas, exports Image, and invokes the same converter.&lt;/p&gt;

&lt;p&gt;The executable is still run separately. In the run below, the saved workbook input was 7. The resulting program returned 7.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PS C:\work\simple-spreadsheet-assembler&amp;gt; .\build.ps1
convert C:\work\simple-spreadsheet-assembler\assembler.ods as a Calc document using filter : Text - txt - csv (StarCalc):44,34,76,1,,0,false,true,false,false,false,2
Writing sheet Image -&amp;gt; C:\work\simple-spreadsheet-assembler\artifacts\export-&amp;lt;id&amp;gt;\assembler-Image.csv
Wrote 1536 bytes to C:\work\simple-spreadsheet-assembler\program.exe
PS C:\work\simple-spreadsheet-assembler&amp;gt; .\program.exe
PS C:\work\simple-spreadsheet-assembler&amp;gt; $LASTEXITCODE
7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Automated build with an input of 7. The CSV remains available under artifacts. The final commands run the executable and read its exit code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kg7hf/simple-spreadsheet-assembler/blob/main/LESSON-2.md" rel="noopener noreferrer"&gt;Lesson two: automate the build with PowerShell&lt;/a&gt; explains the script and its intermediate files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the instructions needed for the test
&lt;/h2&gt;

&lt;p&gt;During DSP bring-up, the immediate goal was to exercise the hardware. A few instruction encodings were enough to start. The rest of the tooling could follow as it was needed.&lt;/p&gt;

&lt;p&gt;This example follows that approach. Change an operand, inspect the bytes, and check the result on Windows. The workbook and CSV leave the intermediate values available for inspection.&lt;/p&gt;

&lt;p&gt;The source and both lessons are in &lt;a href="https://github.com/kg7hf/simple-spreadsheet-assembler" rel="noopener noreferrer"&gt;Simple Spreadsheet Assembler on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about the instruction encoding, file format, or export steps are welcome.&lt;/p&gt;

</description>
      <category>assembly</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>windows</category>
    </item>
    <item>
      <title>The first path</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:37:08 +0000</pubDate>
      <link>https://dev.to/kg7hf/the-first-path-2eje</link>
      <guid>https://dev.to/kg7hf/the-first-path-2eje</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5 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;, 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;, or Part 4, &lt;a href="https://dev.to/kg7hf/acquisition-finding-the-burst-i1l"&gt;acquisition&lt;/a&gt;. The code for this article is on GitHub: &lt;a href="https://github.com/kg7hf/modem-core/tree/part-5" rel="noopener noreferrer"&gt;kg7hf/modem-core&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 4 found the burst on a line with one path. HF seldom has one. The signal reaches the receiver by more than one route through the ionosphere, and each route delivers its own copy of the burst, a few milliseconds after the one before. MIL-STD-188-110B specifies its serial-tone performance on exactly that. In 5.3.2.5, Table XX, the fading channels are two independent Rayleigh paths of equal average power: 2 ms apart for 600 to 2400 bps, and 5 ms apart for 75 to 300 bps and one of the 2400 bps rows. At 2400 symbols a second, that is 4.8 symbols and 12.&lt;/p&gt;

&lt;p&gt;The transmitter sends the burst once; the second copy is the channel's doing. Each copy carries the whole preamble, so acquisition can find either one, and the standard does not say which one a receiver should synchronize to. That is an implementation choice, and this article is mine: the receiver anchors on the first path, not the strongest. It covers why, how the receiver finds the first path even when the scan formed no group for it, how close a path has to be before the anchor moves to it, and what the choice measured.&lt;/p&gt;

&lt;p&gt;Part 4 had &lt;code&gt;acquisition_demo&lt;/code&gt;; this article has &lt;code&gt;first_path_demo&lt;/code&gt;. Its steps run on recordings that are not in the repository: they are published beside it as a release, and the build fetches them when asked. Configure with the article data on, build, 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;cmake &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DMODEM_ARTICLE_DATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ON
cmake &lt;span class="nt"&gt;--build&lt;/span&gt; build
./build/first_path_demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;MODEM_ARTICLE_DATA&lt;/code&gt; on, CMake downloads &lt;code&gt;testdata-first-path-v1.zip&lt;/code&gt; from the repository's releases, checks its size and SHA-256 against the ones recorded in &lt;code&gt;tests/data/first-path/pack.json&lt;/code&gt;, and unpacks it into &lt;code&gt;test-data/first-path-v1/&lt;/code&gt; beside the source, where every build of the checkout finds it. An ordinary build downloads nothing. To fetch it by hand, download the same archive from the release page, check its hash, and unpack it in the same place; &lt;code&gt;first_path_demo --data-dir&lt;/code&gt; reads it from anywhere else.&lt;/p&gt;

&lt;p&gt;The steps run on recordings, not on a channel the program simulates: the "Hi" message from Parts 2 to 4, sent through Table XX's two-path channels with pathsim, in the mode each channel is specified for. I chose each recording because it shows one situation this article explains, so they are examples, not statistics; the statistics come from the campaign later in this article.&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;two_groups()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two paths, two groups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;anchor_window()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An earlier group, and the window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;faded_direct()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A path with no group of its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sidelobe_model()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reference's own sidelobes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sidelobe_path()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Peaks, and the walk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;arrival_phase()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A weak first path, and where the samples fall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As in Parts 3 and 4, &lt;code&gt;tests/first_path_tests.cpp&lt;/code&gt; calls the same functions and checks what they returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Since Part 4
&lt;/h2&gt;

&lt;p&gt;Each part also brings the code already published up to date. Three changes since Part 4:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The transmitter's pulse is a table.&lt;/strong&gt; Part 3 ended its section on the compiler with two loose ends: the root-raised-cosine taps were still computed with the library's cosine when the modem started, and Windows had not been checked. The pulse is now a table of exact values, &lt;code&gt;modem/m110a/body_shaping_pulse.hpp&lt;/code&gt;, written once by &lt;code&gt;tools/generate_shaping_pulse.py&lt;/code&gt;, and a test checks it against the run-time design to within 1e-6. With nothing left for a math library to round, the "Hi" burst's audio is the same bits on Windows and Linux, built with GCC and with Clang, and the golden test now checks every sample of it, as a hash of the float bits, instead of five decimal places. The receiver's matched filter is still designed when the modem starts; that is next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The units have their tests.&lt;/strong&gt; The strong quantity types published with Part 2 (decibels, frequencies, sample rates and counts) come with their tests now. Those tests ran in an older harness of my own, and they move to Catch2 one suite at a time; this is the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test names.&lt;/strong&gt; Catch2 reads a comma in a test spec as "or", so a test whose name has a comma is hard to select by name. Five of Part 4's had one; they are renamed, and the rule is in the repository's contributing guide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two paths, two groups
&lt;/h2&gt;

&lt;p&gt;Part 4's scan takes every start through three gates, the carrier offset, the correlation and a valid read, and groups the candidates that name the same mode and body boundary. With one path, every valid read names the same boundary. With two, the second path's copy of the preamble passes the same gates a path delay later, and its reads name a boundary a path delay later too. Each path forms a group of its own, and &lt;code&gt;strongest_group()&lt;/code&gt; takes the one with the most segments behind it.&lt;/p&gt;

&lt;p&gt;A receiver does not wait for the whole preamble before it commits. In this modem, the receiver that decodes a whole recorded burst tests starts only in the first 1200 symbols it is given, half a second; given a recording that starts with the burst, that is the first two and a half segments. The walkthrough scans the same 1200 symbols of each burst. The rest of the preamble is there for the vote and for the search later in this article.&lt;/p&gt;

&lt;p&gt;Step 1, &lt;code&gt;two_groups()&lt;/code&gt;, runs the scan on the first recording: 300 bps with the long interleave, on the Table's 300 bps channel, two paths 5 ms apart fading at 5 Hz, 7 dB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fp1-echo-group, 300L on Table XX's 300 bps channel: two paths 5 ms (12 symbols) apart, fading at 5 Hz, 7 dB
the scan's groups (starts in the burst's first 1200 symbols):
  body boundary, from the direct path's   supporters  correlation sum  best correlation
  +12.0 symbols, the echo                          3            2.013             0.910  strongest
  +0.0 symbols, the direct path                    1            0.734             0.734
without the anchoring: the echo (body +12.0 symbols from the direct path's)
with it (the window from 7): the direct path (body +0.0 symbols from the direct path's), moved by an earlier group
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through the segments the scan covers, the direct path was the weaker of the two, and the echo's group has more segments behind it. Without the anchoring, acquisition returns the echo's group, as the code published with Part 4 did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the first path
&lt;/h2&gt;

&lt;p&gt;Anchoring on one path does not mean listening to only one. The anchor is the equalizer's time reference, where its window of taps begins. Before training, the receiver measures how far behind the anchor the later paths arrive (the delay spread, Part 6), sizes the equalizer's window to reach them, and the equalizer gathers the energy of every path inside it: when one path fades, the other still carries the symbols. That is why the anchor goes on the first path. The window starts at the first arrival and extends to cover the later one.&lt;/p&gt;

&lt;p&gt;The equalizer works out each symbol from the samples around the anchor, and it treats the two sides differently. Energy that arrives after a symbol's time carries symbols it has already decided, and it can take that energy out using those decisions. Energy that arrives before carries symbols it has not decided yet, and it reaches only a few symbols that way: 6, in the adaptive equalizer's short geometry. Anchored on the direct path, the echo arrives 12 symbols late, on the side the equalizer handles with decisions it already has. Anchored on the echo, the direct path arrives 12 symbols early, beyond what the equalizer reaches, and half the signal is interference on every symbol.&lt;/p&gt;

&lt;p&gt;The strongest path is also a judgement about a moment. At Table XX's 5 Hz fading bandwidth the two paths trade places several times a second, so the strongest group says which path was louder while the scan looked, through the first half second of a burst that lasts several seconds. The first path stays first for the whole burst: its place is set by the length of the route, not by the fading.&lt;/p&gt;

&lt;p&gt;Table XX's channel is a model: two paths a few milliseconds apart, each fading on its own, as one-hop and two-hop modes or the two magneto-ionic modes do on a real link. The waveform is built for that. The known symbols that follow every frame of data, 16 or 20 of them (MIL-STD-188-110B 5.3.2.2.2), let a receiver re-measure a channel a few milliseconds long. A long path arriving tens of milliseconds behind the short path is a different case: at 2400 symbols a second that is a couple of hundred symbols, beyond what those probes can follow, and this receiver does not combine the two. The anchoring window ends at 15 symbols for that reason. Beyond it, the receiver keeps the strongest group, and the other copy is interference. How often real links put a usable second path within reach, and how that changes through a session, is a question for recordings off the air, not for a simulator.&lt;/p&gt;

&lt;p&gt;The full sequence of &lt;code&gt;acquire_body_preamble()&lt;/code&gt;, of which Part 4 showed the first two steps and one vote:&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;strongest&lt;/span&gt; &lt;span class="o"&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="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;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor_earliest_path&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;representative_of&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;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&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="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;earliest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;representative_of&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;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;earliest_path_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;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;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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;earliest&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="n"&gt;anchor_to_precursor_path&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;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;winner&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;winner&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;After the scan and &lt;code&gt;strongest_group()&lt;/code&gt;, &lt;code&gt;earliest_path_group()&lt;/code&gt; looks for an earlier group, &lt;code&gt;vote_mode_and_boundary()&lt;/code&gt; is Part 4's vote, &lt;code&gt;anchor_to_precursor_path()&lt;/code&gt; looks for an earlier path that formed no group, and the vote runs again on the new anchor. &lt;code&gt;anchor_earliest_path&lt;/code&gt; is on by default.&lt;/p&gt;

&lt;p&gt;One thing changed in the part Part 4 showed: a group no longer keeps its best member whole. The anchoring's diagnostics travel with every acquisition result, which made each result about four times larger, and the scan's table of 32 groups would have needed about 7 KB of stack in one function; the repository's checks hold every function in the modem to 4 KB, the bounded stack of Part 1. So a group keeps what the grouping compares and the start where its best member was found, and &lt;code&gt;representative_of()&lt;/code&gt; runs &lt;code&gt;evaluate_start()&lt;/code&gt; at that start again. The function depends on nothing but its arguments, so it gives back the same member, bit for bit, and the frame drops from 8,080 bytes to 2,896 (GCC 14).&lt;/p&gt;

&lt;h2&gt;
  
  
  An earlier group, and the window
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;earliest_path_group()&lt;/code&gt; looks for a second group of the same mode whose body boundary comes before the winner's:&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;group&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;group&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;group&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;candidate&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;group&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;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;earliest&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;earliest_group&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;same_mode&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;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;earliest&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;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;chosen&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;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;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor_maximum_path_delay_symbols&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;chosen&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;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;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor_minimum_path_delay_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;earliest_group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;group&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;A group that close is the same burst through an earlier path. To form a group at all, a start has to clear the 288-chip correlation floor and read a valid mode and countdown (Part 4), so a group a few symbols early is a second path, not noise. The window is set in &lt;code&gt;BodyAcquisitionConfig&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;bool&lt;/span&gt; &lt;span class="n"&gt;anchor_earliest_path&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;true&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;anchor_maximum_path_delay_symbols&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;15U&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// Re-anchor only when the earlier path lies BEYOND the adaptive equalizer's&lt;/span&gt;
    &lt;span class="c1"&gt;// precursor reach (its short geometry: 24 T/2 taps, cursor 12 = 6 symbols).&lt;/span&gt;
    &lt;span class="c1"&gt;// Inside that reach the adaptive equalizer already handles a precursor path&lt;/span&gt;
    &lt;span class="c1"&gt;// (the 2 ms rows of Table XX, 4.8 symbols), and moving the anchor there only&lt;/span&gt;
    &lt;span class="c1"&gt;// re-rolls a marginal equalizer. The 5 ms rows sit at 12 symbols, far beyond&lt;/span&gt;
    &lt;span class="c1"&gt;// it. The host's whole-burst turbo decoder needs the first path even at 2 ms,&lt;/span&gt;
    &lt;span class="c1"&gt;// and runs with 3.&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;anchor_minimum_path_delay_symbols&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The maximum, 15 symbols, is a little beyond Table XX's longest delay of 12. The minimum depends on the equalizer that follows. The adaptive equalizer's short geometry has 24 taps half a symbol apart with its reference tap at 12, so it reaches 6 symbols ahead: a path 7 symbols or more ahead is beyond its taps, and a closer one is within them already. The host's whole-burst turbo decoder needs the anchor on the first path even when the paths are 2 ms apart, so the host runs acquisition with the minimum at 3.&lt;/p&gt;

&lt;p&gt;Step 2, &lt;code&gt;anchor_window()&lt;/code&gt;, runs both windows on the first recording, 5 ms, and on a second: 600 bps with the long interleave on the Table's 600 bps channel, two paths 2 ms apart fading at 1 Hz, 7 dB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the anchor, with the window from 7 symbols and from 3:
fp1-echo-group, 300L on Table XX's 300 bps channel: two paths 5 ms (12 symbols) apart, fading at 5 Hz, 7 dB
  without the anchoring:  the echo (body +12.0 symbols from the direct path's)
  the window from 7:      the direct path (body +0.0 symbols from the direct path's), moved by an earlier group
  the window from 3:      the direct path (body +0.0 symbols from the direct path's), moved by an earlier group
fp2-window, 600L on Table XX's 600 bps channel: two paths 2 ms (4.8 symbols) apart, fading at 1 Hz, 7 dB
  without the anchoring:  the echo (body +4.9 symbols from the direct path's)
  the window from 7:      the echo (body +4.9 symbols from the direct path's), not moved
  the window from 3:      the direct path (body -0.1 symbols from the direct path's), moved by the precursor search, 5 symbols
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 5 ms, both windows move the anchor 12 symbols to the direct path. At 2 ms, the paths are 4.8 symbols apart, inside the adaptive equalizer's reach: the window from 7 leaves the anchor on the echo, and the window from 3 moves it. In this recording the direct path formed no group of its own, so the move at 2 ms is made by the precursor search later in this article; the same minimum bounds that search.&lt;/p&gt;

&lt;p&gt;I measured that difference with a control, a condition where I expected the adaptive receiver to gain nothing: the Table's 1200 bps row, two paths 2 ms apart fading at 1 Hz, at 11 dB, 100 bursts through pathsim, each decoded with the anchoring and without it. With the window from 3, the anchoring moved the anchor 5 symbols earlier in 30 of the 100 bursts. The whole-burst turbo decoder went from 71 exact bursts to 100; in the 30 moved bursts, from 1 to 30. The adaptive receiver gained nothing: 95 exact bursts with the anchoring against 96 without, and in the 30 moved bursts 189 bit errors against 17. With the window from 7, the anchoring never moved the anchor, and both receivers' output was bit-identical to having no anchoring at all. So the window follows the equalizer: from 3 for the turbo decoder, from 7 for the adaptive one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A path with no group of its own
&lt;/h2&gt;

&lt;p&gt;The scan covers two and a half segments. A direct path in a fade through exactly those forms no group, though on average it carries half the burst's energy. &lt;code&gt;anchor_to_precursor_path()&lt;/code&gt; looks for it in every segment. At each lag from 0 to 15 symbols before the anchor, it correlates the prefix in Part 4's nine blocks of 32 chips, adds the blocks' powers, and sums them over every segment from the winner's detected one to the body boundary:&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;lag&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;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_lag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;lag&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="n"&gt;power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0F&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;block&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;block&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;prefix_blocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;block&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;correlation&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;chip&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;chip&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;chip&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;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&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="n"&gt;chip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="n"&gt;correlation&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;segment_start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lag&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;rotated_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="p"&gt;}&lt;/span&gt;

                &lt;span class="n"&gt;power&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;correlation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;precursor_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lag 0 is the anchored path itself; a path 12 symbols earlier shows at lag 12. The reference is turned by the winner's carrier estimate first, &lt;code&gt;rotated_reference&lt;/code&gt;, so the block sums line up. Summing powers over the segments, not the complex correlations, is what lets a fading path register: its phase can turn from one segment to the next, but its power adds.&lt;/p&gt;

&lt;p&gt;Step 3, &lt;code&gt;faded_direct()&lt;/code&gt;, runs on a third recording on the Table's 300 bps channel, chosen because its direct path fades through the segments the scan covers and comes back after them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fp3-faded-direct, 300L on Table XX's 300 bps channel: two paths 5 ms (12 symbols) apart, fading at 5 Hz, 7 dB
the scan's groups (starts in the burst's first 1200 symbols):
  body boundary, from the direct path's   supporters  correlation sum  best correlation
  +12.0 symbols, the echo                          2            1.585             0.826  strongest
without the anchoring: the echo (body +12.0 symbols from the direct path's)
the precursor profile over 23 segments, as a fraction of the anchored path (lag 0):
  lag     1     2     3     4     5     6     7     8     9    10    11    12    13    14    15
      0.024 0.033 0.098 0.016 0.027 0.051 0.030 0.010 0.015 0.011 0.035 0.909 0.068 0.010 0.024
peaks at or above 1/8: 1, the earliest at lag 12 (0.909)
the walk: 3 segments tried; rejected by the carrier gate 0, the correlation floor 2, an invalid read 0, another mode 0
with the anchoring (the window from 7): the direct path (body +0.0 symbols from the direct path's), moved by the precursor search, 12 symbols
the prefix correlation at the segment it read on: 0.697
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan formed one group, the echo's. The profile, summed over the whole preamble, shows the direct path at lag 12, at 0.91 of the anchored echo. The walk rejects the first two segments it tries at the correlation floor, where the direct path was still in its fade, and reads on the third; the anchor moves 12 symbols, to the direct path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reference's own sidelobes
&lt;/h2&gt;

&lt;p&gt;Before lag 0 the profile is not zero, even on one path. Shifted by a few symbols, the prefix correlates partly with itself, and with the end of the segment before it. That end is known: every segment ends with channel symbol 0, whose chips are the bare sync-randomizer values. &lt;code&gt;body_precursor_sidelobe_powers()&lt;/code&gt; computes the profile the reference alone produces, with the previous segment's last 15 chips in front of the prefix, and the search subtracts it, scaled to the anchored path's power:&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;lag&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;lag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maximum_lag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;precursor_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precursor_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&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="n"&gt;sidelobe_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sidelobe_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;precursor_powers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;Step 4, &lt;code&gt;sidelobe_model()&lt;/code&gt;, needs no recording: one clean path, straight from the transmitter. It prints the profile as measured, what a model of the prefix alone leaves after the subtraction, and what the model with the tail leaves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one clean path, 24 segments; precursor power as a fraction of lag 0 (* at or above 1/8):
  lag  measured  less the prefix's sidelobes  less the model with the tail
    1    0.062                        0.000                          0.000
    2    0.003                        0.000                          0.000
    3    0.012                        0.000                          0.000
    4    0.004                        0.000                          0.000
    5    0.034                        0.000                          0.000
    6    0.064                        0.000                          0.000
    7    0.015                        0.000                          0.000
    8    0.010                        0.000                          0.000
    9    0.055                        0.006                          0.000
   10    0.013                        0.002                          0.000
   11    0.024                        0.002                          0.000
   12    0.007                        0.000                          0.000
   13    0.011                        0.003                          0.000
   14    0.029                        0.008                          0.000
   15    0.008                        0.001                          0.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On one clean path the reference's own sidelobes reach 0.064 of lag 0, at lag 6. The model with the tail leaves nothing of them; the prefix alone leaves a remainder, largest at lag 14, which is what the tail is in the model for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peaks, and the walk
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;precursor_peaks()&lt;/code&gt; takes the profile's peaks, not every lag over the floor: a lag whose power is at least 1/8 of lag 0 and no less than either neighbour's, earliest first, up to four. The pulse puts a path's power partly into the lags beside it, so the earliest lag over the floor can be a symbol early; and a peak far out can clear the floor ahead of the real path, so every peak is tried in turn. A peak is still only evidence. &lt;code&gt;walk_to_earlier_path()&lt;/code&gt; tests it on the signal: from the winner's detected segment, it shifts back by the peak's lag and tests one segment at a time up to the body boundary:&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;segment_frequency_radians&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;estimate_body_prefix_frequency_radians&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;segment_start&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="k"&gt;const&lt;/span&gt; &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;segment_frequency_hz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segment_frequency_radians&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;symbol_rate_hz&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;two_pi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;walk_segments&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;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;fabs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;segment_frequency_hz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximum_frequency_offset_hz&lt;/span&gt;&lt;span class="p"&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;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;walk_rejected_frequency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;BodyAcquisition&lt;/span&gt; &lt;span class="n"&gt;shifted&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
        &lt;span class="n"&gt;shifted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_preamble_symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segment_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;shifted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frequency_offset_hz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segment_frequency_hz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;{};&lt;/span&gt;
        &lt;span class="n"&gt;shifted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized_correlation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body_prefix_correlation&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;segment_start&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;segment_frequency_radians&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;shifted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_phase_radians&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// The designator/countdown decode combines echoes over lags 0..15, so it&lt;/span&gt;
        &lt;span class="c1"&gt;// validates any anchor up to a path delay EARLY as readily as the true&lt;/span&gt;
        &lt;span class="c1"&gt;// one; the prefix correlation at the shifted start is the discriminating&lt;/span&gt;
        &lt;span class="c1"&gt;// test (a genuine path clears the acquisition floor there, a phantom&lt;/span&gt;
        &lt;span class="c1"&gt;// does not). A faded segment simply fails it and the walk moves on.&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;shifted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalized_correlation&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimum_correlation&lt;/span&gt;&lt;span class="p"&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;winner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;walk_rejected_correlation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;shifted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;characterize_body_candidate&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;shifted&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;Each shifted start gets its own carrier estimate (the winner's came from the other path, and a fade can bias it by tens of hertz), the 0.35 correlation floor, and a valid read of the same mode. The first segment that passes moves the anchor. The correlation is the test that matters. The designators and the countdown, read from a start a few symbols early, still read correctly, because the decode combines the signal over lags 0 to 15; a valid read does not prove the anchor right. A real path clears the correlation floor at its own start, and a sidelobe does not.&lt;/p&gt;

&lt;p&gt;Step 5, &lt;code&gt;sidelobe_path()&lt;/code&gt;, runs on a recording where the anchor is already on the direct path, with the echo 12 symbols behind it. Shifted 23 symbols back, into the previous segment's closing channel symbol, the prefix correlates with that known tail again; 23 is beyond the 15 lags the search looks at, so the anchored path's model has no entry there. A path d symbols behind the anchor puts that sidelobe 23 - d symbols ahead of it: 11, for the 5 ms rows. Nothing was sent there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fp5-sidelobe-path, 300L on Table XX's 300 bps channel: two paths 5 ms (12 symbols) apart, fading at 5 Hz, 7 dB
the scan's groups (starts in the burst's first 1200 symbols):
  body boundary, from the direct path's   supporters  correlation sum  best correlation
  +0.0 symbols, the direct path                    2            1.274             0.806  strongest
  +12.0 symbols, the echo                          1            0.498             0.498
without the anchoring: the direct path (body +0.0 symbols from the direct path's)
the precursor profile over 23 segments, as a fraction of the anchored path (lag 0):
  lag     1     2     3     4     5     6     7     8     9    10    11    12    13    14    15
      0.034 0.059 0.027 0.020 0.045 0.064 0.045 0.026 0.023 0.067 0.127 0.042 0.032 0.061 0.039
peaks at or above 1/8: 1, the earliest at lag 11 (0.127)
the walk: 23 segments tried; rejected by the carrier gate 21, the correlation floor 2, an invalid read 0, another mode 0
the largest prefix correlation where the walk tested that lag: 0.220
with the anchoring (the window from 7): the direct path (body +0.0 symbols from the direct path's), not moved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The profile shows a peak at 11, at 0.127 of the anchor, just above 1/8. The walk tries 23 segments there and rejects every one: at 21 the carrier estimate falls outside the gate, and at the other 2 the correlation is under the floor; the largest correlation at any of those starts is 0.22. The anchor stays on the direct path. Then &lt;code&gt;vote_mode_and_boundary()&lt;/code&gt; runs once more, on the anchor's own segments, as Part 4's vote did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A weak first path, and where the samples fall
&lt;/h2&gt;

&lt;p&gt;The walk's tests are what reject a sidelobe, and its correlation floor can also reject a real path. A direct path that is weak through the whole preamble still shows in the profile, summed over every segment, but no single segment of it may clear the floor at a shifted start. Whether one does can depend on where the burst's samples fall within a symbol: the front end reads each symbol at one point, 20 samples apart, and a weak path arriving between those points reads lower.&lt;/p&gt;

&lt;p&gt;The streaming receiver (Part 8) met exactly that. A 600 bps, short-interleave recording on the Table's 600 bps channel, two paths 2 ms apart, decoded exactly at only 4 of the 20 sample phases at which the burst can arrive; at the other 16, the direct path's segments did not clear the correlation floor, and the receiver anchored 5 symbols late. The streaming receiver now finds such a path from the profile alone, without the floor: it takes the profile's peak when it reaches 1/8 of the anchor and four times the profile's median, and that recording decodes at all 20 phases.&lt;/p&gt;

&lt;p&gt;Without the walk, the profile alone has to separate a path from a sidelobe, and the first version of that change did not always. On the Table's 2400 bps row with the paths 5 ms apart, one test burst anchored 11 symbols before any signal: the later path's sidelobe at 23 - 12 had passed as a path. The streaming receiver now measures the profile on the later side of the anchor as well, and takes each significant later path's own sidelobes off every earlier lag before it chooses a peak. A higher floor for distant peaks was tried first and dropped: it also rejected real weak paths 12 symbols ahead, at 0.16 to 0.35 of the anchor, in bursts that decode only when anchored on them. The sidelobe sat at lag 11 and the real paths at 12, and their power could not tell them apart; the model of the sidelobes can.&lt;/p&gt;

&lt;p&gt;Step 6, &lt;code&gt;arrival_phase()&lt;/code&gt;, shows the phase on this article's receiver. It runs one recording, a weak direct path on the Table's 600 bps channel, at each of the 20 phases: for phase p it puts p samples of silence in front of the whole recording, and moves the expected answer with it. It runs the short-interleave burst, with three segments to walk, and the same channel with the long interleave, with twenty-four.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Table XX's 600 bps channel: two paths 2 ms (4.8 symbols) apart, fading at 1 Hz, 7 dB: a weak direct path; the window from 3; the burst arriving at each phase of a symbol
  arrival  600S, 3 segments                              600L, 24 segments
    phase  anchored on  how                   walked     anchored on  how                   walked
        0  direct       an earlier group           6     direct       an earlier group           0
        1  direct       an earlier group           6     direct       an earlier group           0
        2  direct       an earlier group           6     direct       an earlier group           0
        3  direct       an earlier group           6     direct       an earlier group           0
        4  direct       an earlier group           9     direct       an earlier group           0
        5  direct       an earlier group           6     direct       an earlier group           0
        6  direct       an earlier group           6     direct       an earlier group           0
        7  echo         not moved                  2     direct       the precursor search       5
        8  echo         not moved                  2     direct       the precursor search       5
        9  echo         not moved                  2     direct       the precursor search       5
       10  echo         not moved                  2     direct       the precursor search       5
       11  direct       an earlier group           9     direct       an earlier group           0
       12  direct       an earlier group           9     direct       an earlier group           0
       13  direct       an earlier group           9     direct       an earlier group           0
       14  direct       an earlier group           9     direct       an earlier group           0
       15  direct       an earlier group           9     direct       an earlier group           0
       16  direct       an earlier group           9     direct       an earlier group           0
       17  direct       an earlier group           9     direct       an earlier group           0
       18  direct       an earlier group           6     direct       an earlier group           0
       19  direct       an earlier group           6     direct       an earlier group           0
on the direct path: 600S at 16 of 20 phases, 600L at 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At most phases the weak direct path clears the floor in the scan and forms a group of its own, and the earlier group moves the anchor to it. At four phases, 7 to 10, it does not. There the short preamble's walk has two segments to try and both fail, so the anchor stays on the echo; the long preamble's walk reads on the direct path at the fifth segment it tries. A decision that depends on a correlation floor depends on where the samples fall, and a test at one arrival phase is one sample of twenty. The campaign below varied each burst's arrival across all 20, and the stored regression sets replay the recording behind the streaming fix at all 20.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it measured
&lt;/h2&gt;

&lt;p&gt;The campaign is one paired test: every burst rendered once through pathsim, the calibrated simulator, then decoded twice, with the earliest-path anchoring and without it (the next two sections call it the rule), by two of this modem's receivers: the adaptive equalizer with its Viterbi decoder, the streaming and embedded path, and the host's whole-burst turbo decoder. At 75 bps only the adaptive receiver decodes the payload. Each burst carried 256 bytes and arrived at one of the 20 sample phases. The Table rows ran 500 bursts each, with the window from 3; at 12 symbols, either window moves the anchor.&lt;/p&gt;

&lt;p&gt;On the 5 ms rows, at each row's Table signal-to-noise ratio, acquisition on its own landed on the later path in 43 of 500 bursts at 300 bps, 35 at 150 bps and 50 at 75 bps. The anchoring moved every one of them 12 symbols earlier and changed nothing anywhere else: outside those bursts, the two decodes were bit-identical.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row (two paths 5 ms apart, 5 Hz)&lt;/th&gt;
&lt;th&gt;Receiver&lt;/th&gt;
&lt;th&gt;Exact, anchoring on&lt;/th&gt;
&lt;th&gt;Exact, off&lt;/th&gt;
&lt;th&gt;In the moved bursts, on / off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;300 bps, 7 dB&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;397 of 500&lt;/td&gt;
&lt;td&gt;364&lt;/td&gt;
&lt;td&gt;36 / 3 of 43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 bps, 7 dB&lt;/td&gt;
&lt;td&gt;turbo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;494&lt;/td&gt;
&lt;td&gt;43 / 37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150 bps, 5 dB&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;490&lt;/td&gt;
&lt;td&gt;33 / 25 of 35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150 bps, 5 dB&lt;/td&gt;
&lt;td&gt;turbo&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;34 / 33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75 bps, 2 dB&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;462&lt;/td&gt;
&lt;td&gt;49 / 12 of 50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 300 bps the anchoring cut the adaptive receiver's bit-error rate from 6.7e-3 to 1.0e-3 over all 500 bursts, and in the 43 moved bursts its bit errors from 5852 to 54. The turbo decoder's errors went from 1222 bit errors, all in the moved bursts, to none. The introduction's "roughly tenfold at 300 bps" came from an earlier version of this receiver; these are today's numbers.&lt;/p&gt;

&lt;p&gt;At 150 bps, one burst went the other way: the anchoring cost the adaptive receiver a burst it decoded exactly without it, 1137 bit errors with the anchoring, while the turbo decoder decoded that burst exactly either way. That one burst makes the row's pooled adaptive bit-error rate higher with the anchoring, though the anchoring won 9 bursts and lost 1, so I quote the row by its counts. One more burst at 150 bps failed with and without the anchoring.&lt;/p&gt;

&lt;p&gt;The same test ran below the Table's signal-to-noise ratios, 100 bursts a row. These results go beyond what the standard asks, and they carry a qualifier: the test system is still being validated. I measured pathsim the way MIL-STD-188-110D Appendix E measures a simulator, for shorter than the appendix asks: with its setting corrected by an offset I measured, 0.4 dB, it delivered within 0.3 dB of the target; the IONOS hardware simulator, measured the same way, delivered about 5 dB more than its setting on fading paths. These numbers are pathsim's, as measured. A hundred short bursts is not the hour Table 10.1 asks for: the runs compare the anchoring on and off, and show where each receiver stops.&lt;/p&gt;

&lt;p&gt;At 300 bps the turbo decoder decoded 98 of 100 bursts exactly at 0 dB with the anchoring, 7 dB below the Table's figure, against 81 without it; at 1 dB, 98 against 87; at 3 dB, 100 against 91. The adaptive receiver decoded 35 of 100 at 5 dB with the anchoring against 31 without, 5 at 3 dB either way, and none below. At 150 bps the turbo decoder decoded 97 of 100 at -2 dB, 7 dB below that row's Table figure, against 84 without the anchoring. At 75 bps the adaptive receiver decoded all 100 at 0 dB with the anchoring, against 84 without it, and 85 against 70 at -2 dB. Below the Table figures, acquisition landed on the later path in 7 to 20 bursts of 100, against 7 to 10 percent at them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not decode both?
&lt;/h2&gt;

&lt;p&gt;Once the receiver has two candidate anchors, the obvious question is why it picks one at all. A PC has the cycles to decode the burst from both and keep whichever result is better. Before building that, I asked what it could gain, and the test data already held the answer. Every burst in the anchor test was decoded twice, once with the rule and once without, so for each burst I could take whichever came out better. That is the most a decode-both receiver could do, because it can only choose between those same two results.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Receiver&lt;/th&gt;
&lt;th&gt;Exact, the rule alone&lt;/th&gt;
&lt;th&gt;Exact, the better of the two&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;300 bps, 7 dB, 500 bursts&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;397&lt;/td&gt;
&lt;td&gt;397&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;turbo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150 bps, 5 dB, 500 bursts&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;turbo&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75 bps, 2 dB, 500 bursts&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1200 bps, 2 ms, 11 dB, 100 bursts&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;turbo&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below the Table SNR, 9 rows of 100&lt;/td&gt;
&lt;td&gt;adaptive&lt;/td&gt;
&lt;td&gt;353&lt;/td&gt;
&lt;td&gt;353&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;turbo (the 7 rows above 75 bps)&lt;/td&gt;
&lt;td&gt;692&lt;/td&gt;
&lt;td&gt;693&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across all 2,500 bursts, decoded by both decoders where both run, choosing the better anchor would have rescued three that the rule alone lost. The two results differ only in the bursts where the rule moved the anchor: 295 of the 2,500. In those, the rule rescued 92 bursts on the turbo and 132 on the adaptive receiver, and lost one and two.&lt;/p&gt;

&lt;p&gt;Two more facts settle it. A receiver that decodes both has to choose without knowing the message, and this waveform carries no checksum: the candidates are the decoder's own quality figure, the share of re-encoded code bits that disagree with the received ones, and whether the end-of-message marker came through cleanly, and either can be wrong. And the cheap form of the idea, decoding twice only when the rule moved the anchor, still adds about a tenth more decoding, for three bursts in 2,500, and the selector would have to pick wrong fewer than three times in those 295 moved bursts to come out ahead. So the receiver keeps the rule and decodes once. If a channel turns up where the rule is wrong more often, the same paired test will show it, and the decision can be made again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing, part two: measuring one change, and running it again
&lt;/h2&gt;

&lt;p&gt;The anchor rule is one change to the receiver, and the test for it was built to show that change and nothing else. Every burst was recorded once and decoded twice, with the rule and without it, so the noise and the fades were identical and any difference came from the rule. I counted the bursts only one side decoded (at 300 bps, 33 with the rule and none without), not just the two averages. I ran a control where I expected no effect, the 2 ms row in the window section, inside the adaptive equalizer's own reach, and it found one: with the window from 3, the turbo decoder went from 71 exact bursts of 100 to 100. Every burst's arrival point within the symbol was varied and recorded, because the anchor decision for a weak first path can depend on it. And I read the exceptions before the averages: at 150 bps one burst where the rule hurt the adaptive receiver was enough to make its average error rate look worse with the rule, while it won the exact-burst count 9 to 1, so that row is quoted by its counts.&lt;/p&gt;

&lt;p&gt;I've found that deep, long-running tests are what separates the academic modem from the operational modem. Decoding a set of test bursts shows that a design works. An operational modem also has to decode for hours at a time, on PCs and sound cards it was not developed on, through the faults the PC and the channel add, and the only way to know it does is to test exactly that. The amount and the quality of testing that takes cannot be thrown together at the end; it has to be planned up front, alongside the modem. I did not do that. I built my tests in a random, ad hoc way, and soon it spiraled out of control and needed to become a more managed and controlled system.&lt;/p&gt;

&lt;p&gt;Testing a modem arguably takes more infrastructure than the modem itself. Behind the numbers in this article are the channel simulators, each measured before its numbers were trusted (Part 4); the signal lab, a board with its own firmware that plays and records audio as a USB sound card and prepares test signals of its own; the waveform generator that renders the test signals on the PC; two other modems as a second opinion on the set-up; the tools that score every decode against the message that was sent; the stored recordings, versioned and checked by hash; and the system that builds the receiver once and runs all of it on several PCs. Each piece needed testing of its own: the hardware simulator delivered more signal-to-noise ratio than its setting (Part 4), and a fault in a bench PC, described below, cost messages on every modem it fed.&lt;/p&gt;

&lt;p&gt;A paired test is worth most when it runs again: on the next build, on another PC, through another channel, without being set up again by hand. Until this month, each campaign was set up by hand; now the test system does that. One request names the code to test, a branch or a commit of the receiver and of the terminal program around it. One PC builds it once, and the same program and the same stored recordings, a versioned set checked by hash, go to the other PCs on my network. Each set of recordings runs whole on one PC and is never split, and the results come back as one report. Every run records the exact source commits and a hash of every input, so a result traces back to what produced it. A set that is missing, skipped or failed fails the whole run, and a test that could not run never counts as a pass.&lt;/p&gt;

&lt;p&gt;Most of those tests feed a recording to the receiver directly, in step with the decoder, not in real time. The result then does not depend on how fast the PC is or what else it is running, so the PCs can share the work: the stored sets give the same results, card for card, on the Linux and Windows builds, and the same as the earlier real-time runs of the same recordings. One limit: a test in step with the decoder cannot show a race between the decoder and audio that keeps arriving, such as a repair still running when the next burst begins. That case stays a real-time test.&lt;/p&gt;

&lt;p&gt;The signal enters at more than one point, and each adds something the one before it lacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pathsim on the PC&lt;/strong&gt;, seeded, so a run repeats exactly and two builds meet the same noise and the same fades. The stored recordings and the paired campaigns come from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signal lab's board as a USB sound card&lt;/strong&gt;, playing a recording in real time, so the receiver hears it through a separate device's sample clock and the PC's audio path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The board's own channel&lt;/strong&gt;: the waveform generator on the board prepares the same test case itself. The short board test plays one case both ways, the PC's recording and the board's own, and both must decode exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The IONOS hardware simulator&lt;/strong&gt;, a real audio path in between, as the independent check on the software simulators from Part 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stored sets, the hour-long recordings, the short board test and the live hour run from the test system on request. Fresh pathsim campaigns like the anchor test, the board's longer playlists and the IONOS runs are still started by hand.&lt;/p&gt;

&lt;p&gt;In real time, the PC is part of the test. A real-time test does not start unless the PC is quiet, with no other receiver and no other heavy job running.&lt;/p&gt;

&lt;p&gt;The stored recordings include an hour of each long-interleave rate from 2400 bps down to 75 bps, each through pathsim on its Table 10.1 row's channel and signal-to-noise ratio (at 75 bps, two equal paths 5 ms apart with a 5 Hz fading bandwidth, at 2 dB). Decoded offline, the decoder runs as fast as it can, so that tests the receiver over a long message, not an hour of real-time reception. So the 75 bps hour is also played live: the whole recording through the board's USB audio, in real time, into the adaptive receiver on Windows, and every one of its 33,750 bytes has to decode exactly. The first live run decoded all 33,750 bytes exactly, with no interruption in the USB audio over the hour. One limit: each hour is one fading realization, and Table 10.1 asks for at least five hours on each fading channel (Part 4); those runs are not part of the test system yet.&lt;/p&gt;

&lt;p&gt;Two results from this project show why the runs have to be long. In Part 4, the timing integrator that a short burst keeps running lost every one of a set of five-minute 75 bps messages on a slow fade (pathsim, two paths 1 ms apart, 0.5 Hz fading bandwidth, at 2 dB). And long plays through the USB sound card lost a message a few times an hour to a gap of 10 to 60 ms in the audio. At 2400 symbols per second, 10 ms is 24 symbols, and the waveform carries nothing that restores symbol alignment mid-message, so each gap cost the rest of the message, on every modem on the bench at the time. The cause was a firewall's network driver holding the PC's processors for tens of milliseconds, long enough to starve the USB audio. Neither shows up in a test as long as one burst.&lt;/p&gt;

&lt;p&gt;The test system is still being validated, as the simulators are, and like the code it is a work in progress. One example: pathsim built for Linux does not render a recording bit for bit as the Windows build does. The recordings differ, in my experience by about one bit, and on the reference set every decode agreed, burst for burst: the same exact bursts and the same bit errors. A difference that size is far below the noise the channel adds, so I treat the two as the same test.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;tests/first_path_tests.cpp&lt;/code&gt; runs every walkthrough step and checks what it returned against the expectations stored with the recordings: two groups and the echo's the strongest, then the direct path with the anchoring on; the window from 7 leaving a 2 ms echo alone and the window from 3 moving it; a precursor path found at lag 12 with no group of its own, and the walk rejecting a faded segment before it reads; the sidelobe at lag 11 rejected by the walk; and the arrival phases, some with the short preamble and all with the long, phase by phase. The sidelobe step's test needs no recording: the model with the tail leaves nothing, and the prefix alone leaves the most at lag 14. A recording that is missing, or whose hash does not match, is a setup error, never a skipped test. Run them on their own with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ctest &lt;span class="nt"&gt;--test-dir&lt;/span&gt; build &lt;span class="nt"&gt;-L&lt;/span&gt; first-path &lt;span class="nt"&gt;--output-on-failure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The runner from Part 4 gains a Part 5 line: where the strongest group put the body, and what the anchoring did with it. It still runs the receive stages through acquisition only; the equalizer comes next week. Run it on a recording from 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/runner &lt;span class="nt"&gt;--wav&lt;/span&gt; test-data/first-path-v1/fp1-echo-group.wav
&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: none; the audio is test-data/first-path-v1/fp1-echo-group.wav
== Channel: the recording's own
== Part 3  front end, Part 3's loop: 10.60 s of audio -&amp;gt; 25431 symbols
== Part 4  acquisition, with the phase search on (it moved the loop 1 times): 300 bps, long interleave
           read at symbol 8884: countdown 7, correlation 0.917, carrier +2.6 Hz
           preamble from symbol 1204, body from symbol 12724
           committed at symbol 1684: the phase search off, the rate integrator tracking
           the clock: the integrator learned +1 ppm through the body
== Part 5  the first path: the strongest group has the body from symbol 12736
           earliest-path anchoring moved it 12 symbols earlier, to an earlier group
== next: the equalizer (Part 6)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ol&gt;
&lt;li&gt;In 5.3.2.5, Table XX, which rows use two paths 5 ms apart? Work out both delays in symbols, and say what a delay that is not a whole number of symbols does to the precursor profile.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;earliest_path_group()&lt;/code&gt; trusts any second group of the same mode a few symbols early. Why can a start a few symbols off the real one not form a group of its own? (Part 4's three gates.)&lt;/li&gt;
&lt;li&gt;Work out the adaptive equalizer's reach from 24 taps half a symbol apart with the reference tap at 12. What would a window from 6 change, on the 2 ms rows?&lt;/li&gt;
&lt;li&gt;The profile is summed over every segment to the body boundary, not over the segments the scan covered. What would summing only those miss?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;body_precursor_sidelobe_powers()&lt;/code&gt; puts the previous segment's last 15 chips in front of the prefix. Why are they known, and why 15?&lt;/li&gt;
&lt;li&gt;Why does &lt;code&gt;precursor_peaks()&lt;/code&gt; take peaks rather than every lag over 1/8? What would a path 4.8 symbols early put at lags 4 and 5?&lt;/li&gt;
&lt;li&gt;The walk tests the correlation at the shifted start before it reads the designators. Why does a valid read of D1, D2 and the countdown not prove an anchor right?&lt;/li&gt;
&lt;li&gt;A path d symbols behind the anchor puts a sidelobe 23 - d symbols ahead of it. For which delays does that land inside the 15 lags the search covers, and what does that say about the 2 ms rows?&lt;/li&gt;
&lt;li&gt;Step 6: with the short preamble, at how many of the 20 phases does the walk reach the weak path? What does that tell you about a test run at one arrival phase?&lt;/li&gt;
&lt;li&gt;The rule moved the anchor in 295 of 2,500 bursts. What would a receiver that decodes both need in order to choose correctly, and what in this waveform could it use?&lt;/li&gt;
&lt;li&gt;Every burst in the campaign was decoded twice. Why does a paired test need fewer bursts than two separate runs to show the same difference?&lt;/li&gt;
&lt;li&gt;Find the known symbols that follow each frame of data in MIL-STD-188-110B 5.3.2.2.2 (and &lt;code&gt;known_symbols_per_probe&lt;/code&gt; in &lt;code&gt;body_waveform.hpp&lt;/code&gt;). How long a channel, in milliseconds, can a receiver re-measure from them, and how far apart would two routes have to arrive to be beyond it?&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Next week: the equalizer. Acquisition hands it an anchor on the first path, a carrier estimate and the body boundary. The first thing it does is measure the channel's delay spread from the same preamble, at the lags behind the anchor this time, and pick its geometry from it. Then it trains on the known symbols and follows the channel through the data, one of three ways: an adaptive decision-feedback equalizer for the streaming and embedded path, a model of the channel solved in one shot, and the whole-burst turbo decoder on the host.&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>dsp</category>
      <category>radio</category>
    </item>
    <item>
      <title>LiTheView is now public: a C++ graphics library and VS Code designer for embedded applications</title>
      <dc:creator>P.R.Decker</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:29:01 +0000</pubDate>
      <link>https://dev.to/kg7hf/litheview-is-now-public-a-c-graphics-library-and-vs-code-designer-for-embedded-applications-55e0</link>
      <guid>https://dev.to/kg7hf/litheview-is-now-public-a-c-graphics-library-and-vs-code-designer-for-embedded-applications-55e0</guid>
      <description>&lt;p&gt;I originally developed LiTheView in the early 2010s. I wanted the familiar experience of building forms with MFC—placing controls, setting properties, and connecting event handlers—but in a graphics library suited to embedded systems.&lt;/p&gt;

&lt;p&gt;Recently, I’ve been refreshing that work with modern C++23, a visual designer integrated into VS Code, and a workflow that lets the same application logic and UI run on Windows and embedded hardware.&lt;/p&gt;

&lt;p&gt;That refreshed project is now &lt;a href="https://github.com/kg7hf/LiTheView" rel="noopener noreferrer"&gt;public on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The idea is straightforward: bring the familiar experience of a visual form designer to embedded C++, while keeping the developer in control of their application, hardware, and memory budget.&lt;/p&gt;

&lt;p&gt;If you remember building forms with MFC—or have used C# visual designers—the workflow should feel familiar. Place controls, edit their properties, generate the UI code, and connect named event handlers to your application.&lt;/p&gt;

&lt;p&gt;The embedded part changes the requirements. Display dimensions, pixel formats, memory capacity, and input devices all matter.&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%2F44bk9vocrp6niyi96qmn.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%2F44bk9vocrp6niyi96qmn.png" alt="LiTheView’s VS Code form designer alongside the H750 application running in its Windows simulator" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The foreground board view is a Windows simulator using a board photograph. Behind it is the same form open in the LiTheView designer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your application stays in charge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LiTheView is a component of your project. It doesn’t become your IDE or dictate your source tree.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyControlPanel/
├── CMakeLists.txt
├── CMakePresets.json
├── Devices/
│   ├── Display/
│   ├── Touch/
│   └── Sensors/
├── Middlewares/
│   └── Third_Party/
│       └── LiTheView/
├── Tasks/
│   └── UI/
│       ├── forms/
│       ├── Generated/
│       └── form_handlers.hpp
└── Platform/
    ├── Windows/
    └── STM32/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer &lt;code&gt;externals/LiTheView&lt;/code&gt; or &lt;code&gt;UI/LiTheView&lt;/code&gt;? Those layouts work too.&lt;/p&gt;

&lt;p&gt;You open your application in VS Code. The designer edits forms and generates portable C++ components. Your existing CMake presets build the application, and your existing debugger launches it. Handwritten application behavior stays separate from generated files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Develop on the PC, then connect the hardware&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The supplied STM32H750 starter demonstrates the model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define the display’s dimensions, native storage format, and logical input.&lt;/li&gt;
&lt;li&gt;Design a form in VS Code.&lt;/li&gt;
&lt;li&gt;Generate its C++ component and connect the handlers.&lt;/li&gt;
&lt;li&gt;Build and debug the Windows application.&lt;/li&gt;
&lt;li&gt;Build the STM32 target using the same application logic and generated UI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The platform implementations change. On Windows, mouse input can represent touch, and a clickable region on a device photograph can represent a physical button. On the board, the corresponding drivers supply those events.&lt;/p&gt;

&lt;p&gt;The H750 example also runs FreeRTOS on Windows, so the simulator exercises the shared application task structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render for the display you actually have&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A small embedded display shouldn’t automatically require a large RGB framebuffer followed by conversion on every update.&lt;/p&gt;

&lt;p&gt;LiTheView supports native rendering formats, including RGB565, XRGB8888, and packed low-bit-depth formats. The selected configuration determines the rendering representation. Display compatibility and target resource budgets are separate checks: you can develop a valid UI before choosing the MCU or receiving the hardware.&lt;/p&gt;

&lt;p&gt;The core is C++23 and has no STM32 HAL, FreeRTOS, Win32, or .NET dependency. Those belong to the application platforms or development tools.&lt;/p&gt;

&lt;p&gt;This is still an evolving project. The current examples, tests, and documentation establish the supported workflows; broader hardware coverage and additional examples will grow from practical use cases.&lt;/p&gt;

&lt;p&gt;The project is available under &lt;strong&gt;GPLv3&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/kg7hf/LiTheView" rel="noopener noreferrer"&gt;Explore LiTheView on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://github.com/kg7hf/LiTheView/blob/main/docs/BUILD_ENVIRONMENT.md" rel="noopener noreferrer"&gt;Start with the setup guide&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I’d especially welcome feedback from developers building custom control panels: what makes the transition from a desktop simulation to your embedded hardware difficult today?&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>embedded</category>
      <category>vscode</category>
      <category>opensource</category>
    </item>
    <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>
