The Story of sb-send
====================

I. The Beginning
----------------

I found an old file called sb-send.c and was not sure if it even
worked. It was 1073 lines of C that tried to speak the SonoBus
protocol from scratch. The author's commit message said as much:
"first version ever that I found. not sure if works."

Looking at that first version, it was ambitious but rough. It used
a member_t struct with a worker thread per peer, had global state
scattered everywhere, and tried to handle the AOO protocol by
hand-building OSC messages. It included send_mute_to_all, which
hinted that muting was already a concern from day one. The code
used poll() for I/O, had a复杂的 audio_master_thread / audio_worker_thread
split, and a send buffer that lived in global state.

That first commit was May 30, 2026, at 1:35 AM. The early dev
patch followed one minute later at 1:36 AM. Someone was working
fast and late.

II. The Patch Parade
--------------------

What followed was a series of patches labeled old/4 through old/11.
Each one was a substantial rewrite. The code churned wildly:

  old/4:  150 lines added, 172 removed. Tweaks.
  old/5:  262 lines added, 100 removed. Growth.
  old/6:  181 lines added, 65 removed. More growth.
  old/10: 835 lines added, 999 removed. A near-total rewrite.
  old/11: 826 lines added, 766 removed. Another near-total rewrite.

The commit messages told the story of someone learning by doing.
"import dev patch." "changes from patch old/10." No explanation
of what changed or why. Just numbers.

By old/10, the code had transformed. The original member_t with
per-peer worker threads was gone. In its place was a simpler Peer
struct, a single AppState, and a cleaner threading model. The
author had figured out that the original approach was too complex.

The old/11 patch was the biggest. It moved from opus.h to
opus_multistream.h, added SLIP framing properly, restructured the
entire peer management, and introduced the Salt concept. The code
went from a messy prototype to something that looked like it might
actually work.

III. First Working Version
--------------------------

Then came the commit that mattered: "First working version I guess.
There was laughter in the air."

The diff from old/11 was small - just 90 lines added, 88 removed.
But those changes were surgical. The field names were corrected
(sink_id became remote_sink_id, source_id became remote_source_id).
The format message builder was fixed to use the right sink ID.
The data message got a proper sequence number. The compact data
message was removed (it was wrong). A format_sent flag was added.

This was the moment the code actually worked. After 8 rewrites
spanning thousands of lines, the fix was under 100 lines. The
lesson was clear: the big rewrites were about understanding the
protocol, not about writing code. Once the understanding was
there, the fixes were small.

IV. The V0.2A Merge
--------------------

The next commit was labeled "Merge changes for V0.2A" and it was
enormous - 2502 lines added, 966 removed. It added a Makefile,
README, protocol documentation (doc/protocol3.txt at 1152 lines),
and completely restructured sb-send.c.

This was the release version. The code now had proper structure:
AppState instead of globals, pthread_mutex for thread safety,
atomic variables for flags, a clean server_thread / udp_thread /
audio_thread split. The Opus multistream encoder was properly
configured. The SLIP framing was correct. The OSC message builders
were clean.

The protocol3.txt file was 1152 lines of documentation about the
AOO protocol, clearly written by someone who had spent a lot of
time reading the Sonobus source code. This was the accumulated
knowledge from all those rewrites.

V. The Drunken Changes
-----------------------

Then came "merged drunken changes that were not shipped with 0.2A."
The diff was small - just 44 lines changed. The changes were
mostly to the Opus encoder configuration: changing stereo from
coupled to uncoupled streams, adjusting complexity settings.

The commit message was honest. Sometimes you code when you should
not. The changes were actually good (uncoupled stereo streams
worked better for SonoBus pairing), but they were made under the
influence and not tested properly.

VI. The Perl Detour
--------------------

"Added untested perl version. probably won't be maintained."
1149 lines of Perl that reimplemented the entire protocol. The
author knew it was a dead end but wrote it anyway. Sometimes you
need to prove something to yourself before you can move on.

VII. The libao Attempt
-----------------------

"First commit of libao attempt." This was a C++ rewrite using the
actual AOO library (libaoo) instead of hand-implementing the
protocol. 601 lines of sb-send-aoo.cpp that used aoo_client,
aoo_source, and the proper AOO API.

But it deleted sb-send.c entirely (1382 lines removed). This was
a bet: throw away the hand-rolled protocol and use the real
library. The bet did not pay off - the libao version was never
finished. sb-send.c was restored in the next commit.

The lesson: sometimes the hand-rolled version, however ugly, is
better than an incomplete rewrite. The C version worked. The C++
version did not.

VIII. The Opus Bug Fix
-----------------------

"Root cause: The Opus codec options blob in the format message
was sending per-channel bitrate instead of total bitrate."

This was the first commit that actually explained a bug. For mono
it worked by accident (1 times N equals N). For stereo it sent
half the expected bitrate. The fix was one line, but finding it
required understanding the AOO protocol deeply enough to know
that the format message expects total bitrate, not per-channel.

The commit message had a special character problem (the multiply
sign showed up as garbage). This was a sign of trouble with the
git configuration.

IX. The Mute Fix Saga
----------------------

Then came the session that produced four commits in rapid
suction. The problem: sb-send.c sent audio to Sonobus clients,
but those clients could not mute it. With "mute all others"
enabled, sb-send.c audio still got through.

First attempt: remove all invite sending. Reasoning: if sb-send.c
does not send invites, the real Sonobus never reciprocates, and
mute works. This was committed as "Fix mute bypass."

But then the user reported: "mute works great after you mute and
unmute once. when sb-send first connects you must mute and unmute
to get audio."

The problem was that the real Sonobus does NOT automatically
invite new peers' sources. It waits for the peer to invite first.
Without sb-send.c's invite, no audio flows.

Second attempt: re-add invite sending, keep the uninvite handler.
The uninvite handler (added in the first attempt) is what makes
mute work. sb-send.c sends the invite to establish the connection.
When the real Sonobus mutes, it sends uninvite. sb-send.c stops
sending.

This was the correct solution. The lesson: understand the protocol
before changing behavior. The real Sonobus has specific expectations
about who invites whom. Working against those expectations breaks
things.

X. The stdin Feature
---------------------

"Add stdin encoding support with ALSA conditional compilation."

The user wanted to pipe audio from sox into sb-send.c. The
implementation added #ifdef ALSA around all ALSA code, a use_stdin
flag, and s16le-to-float conversion in the audio thread. The
Makefile got a nostdin target for building without ALSA.

This was straightforward because the architecture was already
clean. The audio thread was isolated, the encoder was separate
from the input source, and the rest of the code did not care
where the audio came from.

XI. Lessons Learned
-------------------

1. Rewrite less, understand more. The eight rewrites from old/4
   to old/11 were not about better code. They were about
   understanding the AOO protocol. The actual fixes were always
   small once the understanding was there.

2. Commit messages are gifts to your future self. "old/5 changes"
   tells you nothing. "Fix mute bypass" tells you everything.
   The best commit in the history was the Opus bug fix because
   it explained the root cause.

3. The hand-rolled version beats the incomplete rewrite. The libao
   attempt deleted 1382 lines of working C to write 601 lines of
   unfinished C++. The C version was restored. Use the library
   if you can, but do not throw away working code to do it.

4. Protocol compliance is not optional. The real Sonobus has
   specific expectations about invite/uninvite flow. Working
   around those expectations (like not sending invites) breaks
   things in subtle ways. Read the reference implementation.

5. Muting is a protocol-level concern, not an application-level
   one. The -m global mute option was removed because it tried
   to solve at the audio level what should be solved at the
   protocol level. The real Sonobus mutes by uninviting, and
   sb-send.c should respect that.

6. Special characters in git commits are a trap. The Opus fix
   commit had a multiply sign that showed up as garbage in git
   log. Use only ASCII.

7. Sometimes the commit message says it all. "First working
   version I guess. There was laughter in the air." You can
   feel the relief. "merged drunken changes that were not shipped
   with 0.2A." You can feel the regret. The human story is in
   the messages, not the diffs.

XII. The Numbers
----------------

The project went through 18 commits. The code started at 1073
lines, shrank to 1112, grew to 1388, was deleted entirely for a
601-line C++ attempt, restored, and eventually settled at 1451
lines.

The total lines changed across all commits: approximately 12,000
additions and 8,000 deletions. For a program that captures audio
and sends it over the network, that is a lot of churn.

The final code is clean, well-structured, and does one thing. It
took 18 commits to get there. That is the nature of hardware
interface programming: you are reverse-engineering a protocol while
simultaneously implementing it, and every assumption you make can
be wrong.

The story of sb-send is the story of learning by doing. Each
rewrite was a lesson. Each broken commit was a discovery. The
code that works today exists because of every line that was
deleted yesterday.

XIII. The Packet Panic
-----------------------

sb-rec had a -r flag that wrote raw opus packets to stdout. The
idea was simple: capture the opus frames, pipe them somewhere else
later. But "somewhere else" turned out to be a problem. Nobody
reads raw opus. Not opusdec. Not opusinfo. Not any player on
earth. You need a container. The Ogg container.

So opus2ogg.c was born. A small tool: read length-prefixed raw
opus packets from stdin, wrap them in Ogg pages, write to stdout.
The Ogg spec is well documented. How hard could it be?

Very hard, as it turned out.

The first attempt was a hand-rolled Ogg page writer. Custom CRC32,
custom segment table construction, custom page header. The CRC
matched byte-for-byte when verified against libogg. The pages
looked right. But libogg's ogg_sync_pageseek found nothing.
Nothing. The CRC was correct and the parser rejected it.

There is a particular kind of frustration in having the right
answer and not understanding why it is wrong. The code was
rewritten to use libogg instead. That worked in five minutes.
The lesson: Ogg is simple enough to understand but subtle enough
to get wrong in ways that are invisible to the eye.

XIV. The Decode That Was Not Broken
------------------------------------

Then came the real scare. opus2ogg produced a file. opusinfo
accepted it. opusdec rejected it. "OP_EBADHEADER." Every packet.
Every file. Two independent test recordings, both failed.

The conclusion was immediate and alarming: sb-rec's opus packets
are not decodable by standard libopus. The TOC byte showed
config=15 (narrowband mode), stereo=0. But sb-rec records at
48kHz. The encoder was producing narrowband packets for wideband
audio. Something was wrong with the format.

Hours were spent debugging the Ogg container. The container was
fine. The packets inside were the problem.

Then the user said: "Start at the beginning. Make sure the test
packet file can be decoded by the multistream decoder itself
before we try to container it up."

The test was rewritten. Buffer size: 1920 samples instead of 480.
Every single packet decoded. All 2014 of them. Zero failures.

The packets were never broken. The test was wrong. Config 15 is
CELT at 20ms, which means 960 samples per frame. Two frames per
packet means 1920 samples total. The test had been passing
frame_size=480 to a decoder expecting 1920. The "buffer too small"
error meant exactly what it said.

The lesson: when you think the library is broken, check your test
first.

XV. The Granule Problem
------------------------

With packets confirmed decodable, the Ogg container worked. But
opusinfo printed warnings: "Sample count behind granule." The
granule position in each Ogg page was too high.

The cause was a subtle mistake in reading the Opus TOC byte.
Frame count code 2 means "two frames of different size" -- two
frames, not three. The code was computing (toc & 3) + 1, which
gave 3 for code 2. Every packet with code 2 had its samples
overcounted by 50%.

The fix was a switch statement on config & 3 mapping to the
correct frame sizes: 120, 240, 480, 960 samples. The granule
accumulated correctly. opusinfo went silent. opusdec decoded
clean.

The lesson: read the spec, not your assumption of the spec. The
Opus TOC byte frame count codes are 0=1 frame, 1=2 equal, 2=2
different, 3=arbitrary. Code 2 does not mean 2+1=3 frames.

XVI. sb-rec Gets Its Own Ogg
-----------------------------

The user wanted sb-rec to write Ogg directly, without the two-step
pipe through opus2ogg. A new -o flag was added. No decode, no
re-encode -- the opus packets go straight into the Ogg container.

The tricky part was the signal handler. When you press Ctrl-C on
sb-rec, it needs to finalize the Ogg stream: write the last packet
with the EOS flag, flush the remaining pages. You cannot call
ogg_stream functions from a signal handler (they are not
async-signal-safe). So the signal handler sets a flag, the threads
drain, and main() calls ogg_finish() after the join.

The same treatment was applied to opus2ogg itself, which reads
from a pipe that could be cut short by a signal. SIGINT now
triggers a clean shutdown: the buffered last packet gets EOS, the
final page is flushed, and the file plays to the end.

libogg was added as a dependency to sb-rec. For Linux this was
trivial (-logg). For Windows cross-compilation, libogg was built
from source -- two C files, framing.c and bitwise.c -- and linked
statically. The Makefile now builds libogg for both Win64 and
WinARM64.

XVII. The Numbers Update
--------------------------

The project is now at 60 commits. sb-rec.c grew from 350 lines
to 1000 with the Ogg output feature. opus2ogg.c settled at 210
lines after multiple rewrites. The Makefile handles 10 build
targets including the Windows cross-compile with libogg.

The total: sb-send, sb-rec, and opus2ogg form a complete audio
pipeline. sb-send captures and sends. sb-rec receives and
decodes (or wraps in Ogg). opus2ogg converts raw captures to
playable files. Ctrl-C on any of them produces a clean output.

The story continues. Each feature is a small battle against a
protocol or a library or a spec that seems simple until you try
to make it work. The Ogg container is 30 pages of specification
and it took three attempts to get right. That is the nature of
format engineering: the devil is in the granule.
