A brief look at Claude Sonnet 5

I had a pretty straight forward task for what I wanted to do: a software defined single sideband receiver using the RTLSDR product. I've done this before myself in Python, but was less than thrilled with the implementation for a number of reasons. I decided I would attempt to build this in C++. My original solution was using GNURadio which is actually natively C++ anyways.

If you're willing to give up your email address you can get access to the Claude models for free in a limited manner. This is the model I selected for this, since Opus is not available in this free tier

Since I've done this before I know almost exactly what to ask for. I will go ahead and point out that I have experience using Claude at my job, so I am not completely inexperienced in this manner. I came up with this prompt

I am running Ubuntu Linux. I have an RTLSDR v3. There is good support with userspace libraries for interacting with this device to get raw samples. What I want to do is to set the tuning frequency to 50.0 MHz and then use single side band (upper) demodulation to extract audio carriers from the I/Q data stream this device produces. There will be more than one audio stream extracted, the exact frequency offset needs to be configured. The audio format can be raw 16-bit samples at a sample rate of 8000 samples per second (make this configurable). The audio samples output can come out streaming over a local unix socket. This should be done in modern C++ . Follow best practices. Please build this around a standard set of libraries such as GNUradio and the gr-osmosdr libraries. Use standardized blocks for common operations in SDR processing. Since you do not have access to the hardware, you can skip any tool invocations to test this. I can perform validation steps and feedback here as needed if there are issues.

I instructed Claude to skip much tool invocation as I don't think it is going to be a good usage of the free plan. I submitted this and waited for a while. I am assuming that processing requests from the free tier are low priority and not give much compute resources. After a while Claude responded with two files: one being a ZIP file and one being a markdown file. The markdown file it gave me is actually contained with the ZIP file. So I can basically ignore that one entirely. These are the files it generated.

 ssb-multi-receiver.zip 18.9 kB

The original source code generated by Claude

ssb-multi-receiver/
ssb-multi-receiver/src
ssb-multi-receiver/src/UnixSocketSink.cpp
ssb-multi-receiver/src/main.cpp
ssb-multi-receiver/src/ReceiverFlowgraph.cpp
ssb-multi-receiver/src/ChannelBuilder.cpp
ssb-multi-receiver/src/Config.cpp
ssb-multi-receiver/README.md
ssb-multi-receiver/scripts
ssb-multi-receiver/scripts/listen.sh
ssb-multi-receiver/include
ssb-multi-receiver/include/ssbrx
ssb-multi-receiver/include/ssbrx/ChannelBuilder.hpp
ssb-multi-receiver/include/ssbrx/UnixSocketSink.hpp
ssb-multi-receiver/include/ssbrx/ReceiverFlowgraph.hpp
ssb-multi-receiver/include/ssbrx/Config.hpp
ssb-multi-receiver/CMakeLists.txt
ssb-multi-receiver/config
ssb-multi-receiver/config/example.json

So Claude generated

  1. a C++ program, with proper headers
  2. a CMake build system
  3. a readme
  4. an example configuration

Notably, the readme includes information about what packages to install:

sudo apt install build-essential cmake pkg-config \
    gnuradio-dev gr-osmosdr librtlsdr-dev nlohmann-json3-dev socat alsa-utils

While I could figure this out, it's quite helpful.

Getting the build system to work

I went to compile this by using the cmake build system. When I invoked cmake I ran into this error

CMake Error at /usr/share/cmake-3.28/Modules/FindPkgConfig.cmake:619 (message):
  The following required packages were not found:
   - gnuradio-pmt

I threw this back at Claude and asked for a solution, my follow up was this

I have followed your steps and also made sure I have run "sudo apt-get install libgnuradio-pmt3.10.9t64" I am on ubuntu 24.04 specifically. Running cmake .. fails with this CMake Error at /usr/share/cmake-3.28/Modules/FindPkgConfig.cmake:619 (message): The following required packages were not found: - gnuradio-pmt

other gnuradio components like "blocks" are found OK. It appears we have some issue locating pmt with the CMake

Claude's response

Good catch — this one has a clean fix. Looking back at the code, we never actually #include <pmt/pmt.h> or call any pmt:: functions directly anywhere in our source — I added the gnuradio-pmt pkg-config requirement preemptively as a "usual suspect" component, but it isn't something our code touches directly. libgnuradio-runtime already depends on libgnuradio-pmt internally, so the dynamic linker pulls it in transitively at runtime regardless — we don't need to ask pkg-config to find it ourselves.

Simplest fix: drop it from CMakeLists.txt entirely.Confirmed — pmt only shows up in CMakeLists.txt, never in actual source. Safe to remove.If you'd rather patch your existing checkout by hand instead of re-downloading, just delete these two lines from CMakeLists.txt:

pkg_check_modules(GR_PMT     REQUIRED IMPORTED_TARGET gnuradio-pmt)

and

  PkgConfig::GR_PMT

(the second one is in the target_link_libraries block, right after PkgConfig::GR_ANALOG).

Re-run cmake .. and it should get past that error. If a link step later fails with something like undefined reference to pmt::..., that would mean my assumption about transitive linking doesn't hold on your system — in that case paste me the error and we'll track down whatever the real package name for libgnuradio-pmt3.10.9t64's dev files is (the t64 suffix is from Ubuntu 24.04's 64-bit time_t ABI transition, which is likely why the package layout doesn't quite match older tutorials/docs you might find online).

Fixing cmake

I was able to apply the fix from Claude directly. Removing this solved that problem. As it turns out, this was the limit of usage of the free tier for whatever time period they have.

Compilation failures

With the build system fixed I tried to actually compile the code. This did not work.

Missing headers

The first issue I discovered was there were some missing headers. The original response from Claude mentioned this. Since I am using GNURadio 3.10 I ran the provided commands and found out some headers had been renamed

  1. gnuradio/filter/freq_xlating_fir_filter_ccf.h -> gnuradio/filter/freq_xlating_fir_filter.h
  2. gnuradio/filter/rational_resampler_fff.h-> gnuradio/filter/rational_resampler.h
  3. gnuradio/blocks/multiply_const_ff.h -> gnuradio/blocks/multiply_const.h

These includes were used in a single file src/ChannelBuilder.cpp. Updating that file fixed this include.

Renamed constants

I then encountered errors around the constant gr::filter::firdes::WIN_HAMMING also used in src/ChannelBuilder.cpp. A quick google search found the documentation for GNURadio indicates this constant now lives at gr::fft::window::WIN_HAMMING. There were only 2 usages of this so refactoring that was simple.

A linking failure

The next error I got was a linking failure during the end of compilation.

/usr/bin/ld: CMakeFiles/ssb_multi_receiver.dir/src/main.cpp.o: undefined reference to symbol '_ZN3fmt2v96detail9dragonbox10to_decimalIfEENS2_10decimal_fpIT_EES5_' /usr/bin/ld: /lib/x86_64-linux-gnu/libfmt.so.9: error adding symbols: DSO missing from command line

I was quite confused until I figured out that "dragonbox" is apparently something related to decimal representations and printing them. It became obvious that this something do with a library not being setup in CMake correctly. In CMakeLists.txt I added the line find_package(fmt 9 REQUIRED) then updated the link libraries to start with target_link_libraries(ssb_multi_receiver PRIVATE fmt::fmt. This fixed this problem and the program compiled.

Does it work?

At this point I had a compiled program. The next question wasd oes it do anything useful? I was able to use the included configuration file that Claude generated to test the program by running ./ssb_multi_receiver --config ../config/example.json. The program starts up and says

$ ./ssb_multi_receiver --config ../config/example.json 
Waiting for a client to connect on each channel's Unix socket...
  [ch0_50313] listening on /tmp/ssb_ch0.sock
  [ch1_50280] listening on /tmp/ssb_ch1.sock

So at this point I just had to connect clients. This is easy enough I just ran the following commands

  1. nc -U /tmp/ssb_ch0.sock | aplay --rate=8000 --format=S16
  2. nc -U /tmp/ssb_ch1.sock > /dev/null

The first command just uses aplay to play audio on the default device. When I did this, I immediately heard static. The antenna I am using at this point is just a small handheld whip antenna meant for use on a walkie-talkie. I moved it over towards the USB hubs on my desk and the noise floor moved way up. Most electronics generated a decent amount of noise at a wide range of frequencies. So this demonstrated it was working just fine.

The second command just discards the audio samples from that channel.

Claude got a few things wrong and lots of things right

By my count Claude got 5 things wrong. If I hadn't of hit usage limits, I would think I could prompt Claude with errors and ask for a resolution. In any case, I was able to fix it myself easily enough.

Here's what is really cool, this is the configuration file Claude generated for me

{
  "device": {
    "args": "rtl=0",
    "center_frequency_hz": 50000000.0,
    "sample_rate_sps": 2400000.0,
    "manual_gain": true,
    "gain_db": 30.0,
    "freq_correction_ppm": 0.0,
    "bandwidth_hz": 0.0
  },
  "channels": [
    {
      "name": "ch0_50150",
      "offset_hz": 150000.0,
      "sideband": "usb",
      "intermediate_rate_sps": 48000.0,
      "audio_rate_sps": 8000.0,
      "low_cutoff_hz": 300.0,
      "high_cutoff_hz": 3000.0,
      "transition_width_hz": 500.0,
      "volume": 1.0,
      "socket_path": "/tmp/ssb_ch0.sock"
    },
    {
      "name": "ch1_50250",
      "offset_hz": 250000.0,
      "sideband": "usb",
      "intermediate_rate_sps": 48000.0,
      "audio_rate_sps": 8000.0,
      "low_cutoff_hz": 300.0,
      "high_cutoff_hz": 3000.0,
      "transition_width_hz": 500.0,
      "volume": 1.0,
      "socket_path": "/tmp/ssb_ch1.sock"
    }
  ]
}

Claude got the things I asked for configurable: the output audio rate and the number of software defined receivers. For the remaining items Claude went ahead and made them configurable, assumed reasonable defaults and generated a working configuration. This is a big difference from Google Gemini, where just getting it to define constants for common values and reuse them was quite difficult.

Actually receiving signals

The original frequency I had asked for was 50.0 MHz. To actually receive some kind of transmitted signal at this frequency I'd need to hook up to a large outdoor antenna which I don't want to do. I have a bunch of antennas for other frequencies, so I just ran a long piece of coax to my desk and connected to that.

I made up this configuration file

{
  "device": {
    "args": "rtl=0,direct_samp=0,buffers=15,buflen=512",
    "center_frequency_hz":   28000000.0,
    "sample_rate_sps": 2400000.0,
    "manual_gain": true,
    "gain_db": 30.0,
    "freq_correction_ppm": 0.0,
    "bandwidth_hz": 0.0
  },
  "channels": [
    {
      "name": "ch1",
      "offset_hz": 74000.0,
      "sideband": "usb",
      "intermediate_rate_sps": 48000.0,
      "audio_rate_sps": 12000.0,
      "low_cutoff_hz": 300.0,
      "high_cutoff_hz": 3000.0,
      "transition_width_hz": 500.0,
      "volume": 1.0,
      "socket_path": "/tmp/ssb_ch0.sock"
    },
    {
      "name": "ch2",
      "offset_hz": 300000.0,
      "sideband": "usb",
      "intermediate_rate_sps": 48000.0,
      "audio_rate_sps": 12000.0,
      "low_cutoff_hz": 300.0,
      "high_cutoff_hz": 3000.0,
      "transition_width_hz": 500.0,
      "volume": 1.0,
      "socket_path": "/tmp/ssb_ch1.sock"
    }
  ]
}

This tunes the receiver to 28 MHz and puts sideband receivers at 28.074 MHz and 28.300 MHz. Since Claude gave access to all the configuration parameters, I was able to set "args": "rtl=0,direct_samp=0,buffers=15,buflen=512" which is used to setup the receiver. Adjusting the buffers minimizes the audio delay. I actually meant to set direct_samp=2 to disable the intermediate frequency in the receiver. This is normally done when using this receiver at such a low frequency. I guess it still works to some degree even without this.

To run the receiver software I run sudo taskset --cpu-list 1-3 chrt -f 49 ./ssb_multi_receiver --config ../config/ft8_10m.json. I am running this presently on a tiny Intel N150 computer which is already quite pressed to the limit. Running it with this command sets the priority to realtime but limits it to 3 of the 4 available CPU cores. If I don't do this, there are buffer underrun issues. To listen to the audio I wound up running

  1. sudo nc -U /tmp/ssb_ch0.sock | aplay --rate=12000 --format=S16
  2. sudo nc -U /tmp/ssb_ch1.sock > /dev/null

I received FT8 transmissions on the first channel. I recorded 5 minutes of that by running

ffmpeg -f pulse -i @DEFAULT_MONITOR@ -t 300 system_audio.wav

Then I generated a waterfall of that. The high signal intensity segments are FT8 transmissions from amateur radio operators. Each carrier is around 50 Hz wide, which is what is expected when receiving FT8. So this proves that the receiver software generated by Claude works.

Getting the program to stop

One curious thing I noticed was that even after all the unix socket clients disconnected the program just continued running along happily forever. This is because internally GNURadio just blocks when it can't send data. Apparently whatever limits I was hitting with Claude had expired by the time I got to this point. So I sent it back my updated code with this prompt

after some further work I have updated the code to this (see attached). From what I can see when the sockets disconnect nothing happens and it continues running indefinitely. So the program does not terminate on its own, even when doing nothing useful. Can we start a thread that detects the socket close? When one is found to have closed, it can shutdown the flowgraph and stop the program.

Claude's response

Unchanged from what I gave you, good. Let me add the disconnect-detection API here.Now the implementation:Now wire it into main.cpp:One more defensive addition: since this program deliberately writes to sockets whose peer can vanish at any time, let's also make sure a stray SIGPIPE (the classic "write to a closed pipe/socket kills your process" signal) can never take the whole thing down unexpectedly — belt-and-braces alongside the disconnect watcher.Now let's re-package and hand back the updated project:Good news first: your local fixes (correct headers for GR 3.10, gr::fft::window::WIN_HAMMING, rtl=0,buffers=15,buflen=512, FT8/40m configs, chrt priority note) all made it into this update untouched — I built on top of your working copy.

Why it was hanging forever: writing to a socket after the client vanishes normally raises SIGPIPE, whose default action kills the process — but something in the GNU Radio/osmosdr stack is evidently already ignoring SIGPIPE globally, so file_descriptor_sink's write() calls were just silently failing (EPIPE) forever instead. Nothing was watching for that, so the flowgraph kept running with a dead sink.

What I added:

  1. duplicateFd() + waitForDisconnect() in UnixSocketSink.{hpp,cpp}dup()s each client fd before handing the original to GNU Radio, so there's an independent descriptor to poll() for POLLHUP/POLLERR without interfering with the sink's writes.
  2. main.cpp spawns one watcher thread per channel after flowgraph.start(). The moment any channel's client disconnects, that thread sets the same atomic flag your Ctrl+C handler uses — so a dropped socket now triggers the exact same clean shutdown path (flowgraph.stop()wait() → exit), just from a different trigger.
  3. poll()'s timeout is 250ms rather than infinite, specifically so these threads also notice Ctrl+C promptly and unblock/join cleanly instead of leaking.
  4. Explicit signal(SIGPIPE, SIG_IGN) in main() now — belt-and-braces so this can never regress into a crash-on-write-race instead of a clean shutdown, regardless of what any given library defaults to.

One unrelated thing I noticed: CMakeLists.txt wasn't in the zip you sent back (probably just an artifact of however you packaged it) — I put the last version I gave you (the one with the gnuradio-pmt fix already applied) back in, so this zip should be complete and buildable as-is.

Trying it out and improving it

After recompiling with the updated code, I started everything up them immediately stopped one of the netcat receivers. It produced this output in the terminal

 ::text 

 file_descriptor_sink :error: Broken pipe
Stopping...

It then stopped immediately. Claude implemented this by adding this function to src/UnixSocketSink.cpp

bool waitForDisconnect(int fd, std::atomic<bool>& stopFlag) {
    pollfd pfd{};
    pfd.fd = fd;
    pfd.events = POLLHUP | POLLERR;

    // We never expect to *read* anything on this socket (it's write-only,
    // audio-out); POLLHUP fires once the peer has closed its end, which is
    // exactly the "the listening client went away" event we want to catch.
    // A short poll() timeout, rather than an infinite one, is what lets
    // this loop notice `stopFlag` being set by something else (e.g. a
    // Ctrl+C handler) instead of blocking forever on a socket that never
    // actually hangs up.
    while (!stopFlag.load()) {
        pfd.revents = 0;
        const int ret = ::poll(&pfd, 1, /*timeout_ms=*/250);
        if (ret < 0) {
            if (errno == EINTR) continue;
            return false; // give up monitoring on an unexpected poll() failure
        }
        if (ret > 0 && (pfd.revents & (POLLHUP | POLLERR))) {
            return true;
        }
    }
    return false;
}

This is invoked once per socket in src/main.cpp

  std::vector<std::thread> monitors;
    monitors.reserve(monitorFds.size());
    for (size_t i = 0; i < monitorFds.size(); ++i) {
        monitors.emplace_back([&, i, mfd = std::move(monitorFds[i])]() mutable {
            if (ssbrx::waitForDisconnect(mfd.get(), g_stopRequested)) {
                std::cerr << "  [" << config.channels[i].name << "] client disconnected -- shutting down\n";
                g_stopRequested.store(true);
            }
        });
    }

So each socket has its own thread waiting to detect an event. This actually seems like a pretty odd solution in my opinion. Each thread never really does anything, even the comment from Claude mentions this. At this point I was reasonably certain poll() could be used to do this with a single thread. So I wrote this class to replace the function that Claude had generated.

class socketMonitor {

public:
std::vector<ssbrx::UniqueFd> monitorFds;

socketMonitor() = delete;

socketMonitor(std::vector<ssbrx::UniqueFd> & monitorFds) {
this->monitorFds = std::move(monitorFds);

}
void waitForFirstClose(std::atomic<bool>& stopFlag){
  std::vector<struct pollfd> fds;
  for(size_t i = 0; i != this->monitorFds.size(); i++) {
    struct pollfd v;
    v.fd = this->monitorFds[i].get();
    v.events = POLLHUP | POLLERR;
    fds.push_back(v);
  }

    while (!stopFlag.load()) {
      for(size_t i = 0; i != fds.size(); i++) {
        fds[i].revents = 0x0;
      }
      const int ret = ::poll(fds.data(), fds.size(), 250);      
      if (ret < 0) {
          if (errno == EINTR) {
            continue;
          }
          return; // give up monitoring on an unexpected poll() failure
      }
      if (ret > 0) {
        for (size_t i = 0; i != fds.size(); i++) {
          if (fds[i].revents & (POLLHUP | POLLERR)) {        
            std::cerr << "  [channel " << i << "] client disconnected -- shutting down\n";
            g_stopRequested.store(true);
            return;
          }
        }
      }
    }

  return;
}
};

I don't write C++ very often, but this just invokes poll forever with a 250 millisecond timeout until at least one socket matches the expected condition. In the main function I start this like this

// one thread to watch all the clients for a disconnect.
        // treat that the same as Ctrl+C -- stop the whole
        // flowgraph and exit, rather than continuing to run with a dead
        // consumer on that channel.
        socketMonitor sm(monitorFds);
        std::thread monitorThread([&sm]() {
          sm.waitForFirstClose(g_stopRequested);
        });

I tested this out and it seems to work just fine. The fact that Claude chose a thread per socket is quite disappointing. Starting an entire thread for each socket is far more than what is needed here.

Asking Claude about my changes

I opted to send my improvement back to Claude with this prompt

I didn't like your approach of starting a thread per socket to wait on it to close .I made one thread that uses "poll" to wait for any of them to close. Review my modification (entire source code attached) and critique my implementation

Claude's Response

Nice — consolidating to one thread with a single poll() call across all fds is a genuinely better design than what I did. poll() is built exactly for multiplexing many descriptors on one thread; spinning up N threads to each block on N separate poll() calls was needless overhead on my part. Good instinct to simplify it.

That said, a few things in the implementation are worth fixing:

1. Dangling declaration — real bug waiting to happen. waitForDisconnect() is still declared in UnixSocketSink.hpp but you deleted its definition from the .cpp when you replaced it with socketMonitor. Right now nothing calls it, so it's silent — but if any future code (yours or mine) calls ssbrx::waitForDisconnect(...), it'll compile fine and then fail at link time with an obscure "undefined reference" error. Delete the declaration too:

// remove this from UnixSocketSink.hpp — no matching definition exists anymore
bool waitForDisconnect(int fd, std::atomic<bool>& stopFlag);

2. Missing #include <cerrno> in main.cpp. waitForFirstClose reads errno after poll(), but main.cpp never includes <cerrno> — it's presumably resolving through some transitive include from <iostream>/<thread> right now, which is exactly the kind of thing that quietly breaks on a different libstdc++ version or compiler. Add the include explicitly wherever you read errno.

3. The constructor silently empties the caller's vector.

socketMonitor(std::vector<ssbrx::UniqueFd> & monitorFds) {
    this->monitorFds = std::move(monitorFds);
}
...
socketMonitor sm(monitorFds);   // caller's monitorFds is now empty — but nothing at the call site says so

Taking a non-const reference and moving out of it is a classic footgun: someone reading socketMonitor sm(monitorFds); has no visual cue that monitorFds just got gutted. The idiomatic pattern for a "sink" parameter is take-by-value:

explicit socketMonitor(std::vector<ssbrx::UniqueFd> monitorFds) : monitorFds(std::move(monitorFds)) {}
...
socketMonitor sm(std::move(monitorFds));  // now the call site itself documents the transfer

4. Placement/naming breaks with the rest of the codebase. Everything else here is PascalCase and lives in the ssbrx namespace, either in a header (ReceiverFlowgraph, ChannelBuilder, UniqueFd) or in the anonymous namespace at the top of main.cpp for things truly local to main. socketMonitor does neither — it's lowerCamelCase and sits in the global namespace, right between the anonymous-namespace block above it and main() below. Since this is genuinely reusable socket-plumbing (not main-specific), it fits naturally as ssbrx::SocketMonitor alongside waitForUnixClient/duplicateFd in UnixSocketSink.hpp/.cpp — that also happens to fix point 1 for free, since it'd replace the dead declaration in the same file.

5. Public monitorFds member, non-const waitForFirstClose(). You deleted the default constructor specifically to force valid construction, but then leave the vector public — anything can reach in and mutate it afterward (sm.monitorFds.clear()), silently defeating that invariant. Make it private; waitForFirstClose() doesn't mutate anything, so it can be const too.

6. Lost the per-channel name in the log line. Before: [ch0_50313] client disconnected. Now: [channel 3] client disconnected — because socketMonitor only ever sees raw fds, not names. Index i does line up correctly with config.channels[i] (both are built by iterating the same config.channels/fds in order), so it's an easy fix — just pass the names in too, e.g. std::vector<std::pair<std::string, ssbrx::UniqueFd>>, or a parallel std::vector<std::string>.

7. Pre-existing, not something you introduced: if anything between std::thread monitorThread(...) and monitorThread.join() throws (say flowgraph.stop() does), stack unwinding destroys monitorThread while it's still joinable, which calls std::terminate() — an uncaught exception gets promoted to an abrupt crash instead of hitting your catch block below. Same latent issue existed in the version I gave you with per-channel threads. Worth hardening at some point (an RAII join-guard, or std::jthread if you move to C++20), just flagging it since we're in this code anyway.

Minor nits, not bugs: - Resetting fds[i].revents = 0x0 before each poll() call is unnecessary — poll() fully overwrites revents on every return regardless of its prior value, so that loop does nothing useful (harmless, just dead work). - CMakeLists.txt now has find_package(fmt 9 REQUIRED) and links fmt::fmt, but nothing in src//include/ calls into fmt:: — looks like a leftover from something you were trying, unless it's for work-in-progress not in this snapshot.

Want me to make these edits, or are you mid-iteration on it yourself?

Evaluating Claude's response

Almost all of Claude's feedback is correct. Items 1 & 2 are easy to address. The other issues aren't very serious. I'm well aware that item 3 is not a best practice, so it's good to see it called out by Claude here.

Under "Minor Nits" it makes a comment about CMakLists.txt having a useless find_package(fmt 9 REQUIRED). This is my prior fix to get the code to compile. The generated code obviously does not have this as a dependency but my assumption is the GNURadio somehow does. It seems Claude has failed to understand this entirely. I could at this point prompt with the typical "double check your conclusions, I had to add find_package" to get it to compile. But I am not spending time to do that at present.

Conclusion

I've only evaluated one model here, but Claude seems to be a reasonable tool for tasks like this. I was impressed by the generated product and the fact that it provided a practical level of configuration without it being burdensome. I'm going to revisit Claude with a much more ambitious project related to amateur radio.

 ssb-multi-receiver-final.zip 21.6 kB

The final source code I wound up with for this project


Copyright Eric Urban 2026, or the respective entity where indicated