XIII. The Windows Port
-----------------------

The request came simple: "port this git project sonobus-send to
windows." I had a working Linux sender that spoke the SonoBus
protocol, captured ALSA audio or read from stdin, and sent it to
group members. Now it needed to run on Windows.

The first thing I did was remove the OpenSSL dependency. The
original code used OpenSSL's EVP API to hash passwords with MD5.
The Windows port hardcoded the MD5 of an empty string as a constant
and I wrote a custom MD5 implementation. That turned out to be a
trap. My first MD5 had wrong rotation amounts in rounds 1-4. The
second had the correct algorithm but the output format was wrong
(big-endian hex instead of little-endian byte order). It took
three attempts and verification against RFC 1321 test vectors
before the MD5 was correct. Then the user said they did not need
password support at all, so I removed the -P flag and the custom
MD5 entirely. All that work for nothing, but the hardcoded MD5
constant remained.

The networking was the first real challenge. Windows uses Winsock2,
not BSD sockets. The differences are small but fatal if you miss
them: SOCKET is unsigned, not int. closesocket, not close. MSG
NOSIGNAL does not exist. select() ignores the first parameter.
WSAStartup must be called before any socket operation. I wrapped
every platform difference in macros (sock t, SOCK INVALID, CLOSE
SOCKET, SOCK ERR) so the rest of the code could stay the same.

The thread model stayed pthreads via winpthreads. The llvm-mingw
toolchain includes winpthreads, which provides pthread create,
pthread mutex, and pthread cond timedwait on Windows. This was
the right call. The code uses mutex and condvar heavily for the
ring buffer and peer management. Switching to Windows threads
would have meant rewriting all of that.

The audio capture was the hardest part. Linux has ALSA, which
blocks in snd pcm readi until exactly one block of audio is
available. Windows has WASAPI, which is event-driven and delivers
variable-size chunks. My first attempt used a ring buffer with a
separate reader thread, like the stdin path. It worked but had
more dropouts than stdin. The user said it was "much worse."

I tried direct capture in the audio thread, no ring buffer, no
extra thread. That was even worse. WASAPI delivers data in bursts,
not evenly, and without the ring buffer to smooth things out, the
audio was choppy.

The solution was a float ring buffer with a small prefill. The
WASAPI reader thread captures float samples from the device, puts
them in the ring buffer. The audio thread reads from the ring
buffer with Sleep-based timing. The prefill is 200ms instead of
the 1 second used for stdin. No float-to-int16 conversion. The
ring buffer absorbs timing jitter. The Sleep-based timing ensures
regular 10ms encode intervals. This gave clean audio with low
latency.

Then came the channel mismatch. Windows audio drivers often
default to stereo even when the user requests mono with -c 1. The
driver rejects the 48kHz mono format and falls back to the device
native format (usually 44.1kHz stereo). The old code silently
changed state->channels to match the device, so the Opus encoder
would encode stereo when the user wanted mono.

The fix: never modify state->channels. Add a separate
wasapi_channels field for the device actual channel count. When
they differ, downmix in the audio thread before encoding. The
downmix sums all device channels and divides by the count. For
2-to-1, it averages left and right.

The next puzzle was why the Linux build broke when I changed
select(0, ...) to select(sock + 1, ...). On Windows, select()
ignores the first parameter. On Linux, it is critical. Passing 0
meant "check no file descriptors" so select() never fired, and
every connection timed out. The fix was to pass sock + 1 on both
platforms.

Then came the invite/format spam. When two sb-send instances
connected to the same group, they kept sending format messages to
each other every second. The root cause was that receiving a
format message triggered sending a format message back. Removing
that handler fixed the spam. But then uninvites were also needed.
Sb-send is sender-only. It does not receive audio. When it
connects to a SonoBus client, it should tell that client "do not
send audio to me." So after sending format in response to an
invite, sb-send now sends uninvite to source 0 (the dummy source
that initiated the handshake).

The Windows-specific bug was the hardest to find. On Linux,
uninvites worked perfectly. On Windows, they sent nothing. The
uninvite was being sent to /aoo/src/<their sink id>/uninvite
instead of /aoo/src/0/uninvite. The wrong source id meant the
remote peer silently dropped the message. On Linux the peer was
more lenient. On Windows it was strict. The fix: always target
source 0 (the dummy source) in the uninvite address.

The locking audit was sobering. I found that p->format sent was
being written outside the mutex after an unlock. The audio thread
reads format sent under the mutex. The uninvite handler writes it
under the mutex. Writing it outside the mutex was a data race. I
moved it inside the mutex block. The server thread maintenance
loop also had a subtle issue: it iterated over peers while
unlocking the mutex to send messages. After re-locking, the peer
count could have changed. I added a local snapshot of peer count
before the loop.

The final issue was the reconnection invite showing sink=0 src=0.
When a peer was uninvited, the old code zeroed out remote sink id
and our source id. Then the maintenance loop retried the invite
using our source id, which was now 0. The fix: do not zero the
ids on uninvite. Just set connected to false. The ids are still
valid for reconnection.

The build system was straightforward. The llvm-mingw toolchain
provides clang cross-compilers for both x86 64 and aarch64
Windows targets. Opus was already built as a static library for
both architectures. The Makefile has targets for win64 and
winarm64. Both produce statically linked executables with no
runtime dependencies.

The final binaries: sb-send-win64.exe at 817K, sb-send-winarm64.exe
at 731K. Both statically linked. The Linux binary is 67K
dynamically linked. The size difference is mostly static libc and
winpthreads.

XIV. The Numbers
----------------

The Windows port changed about 400 lines in a 1451-line file. The
changes touched every platform-dependent path: networking, audio
capture, signal handling, timing, and sleep. The platform-specific
code is guarded by ifdef blocks, with the shared protocol logic
unchanged.

The project now builds on three targets from one source file:
Linux ALSA, Linux stdin, Windows x86 64, and Windows aarch64. The
code is about 1800 lines. It took about a dozen back-and-forth
sessions to get the Windows port working correctly, with each
session revealing bugs in both the new Windows code and the
original Linux code.

The lesson from the Windows port is the same as the lesson from
the original project: the platform-specific code is never the hard
part. The hard part is the protocol. Every bug we found was in the
shared code, not in the ifdef blocks. The format spam, the
uninvite targeting, the mutex races, the locking patterns. These
were all protocol and threading bugs that affected both platforms.
The Windows port just happened to expose them because the timing
and error handling were different enough to make latent bugs
visible.
