Session Trace Report
====================
Date: 2026-06-12
Project: sonobus-send (sb-send.c)

1. INITIAL INVESTIGATION
------------------------

The user asked to investigate problems with sb-send.c and muting of
other group members. The reported symptoms were:

  A) Other Sonobus instances could not always mute audio from sb-send.c
  B) When a real Sonobus client joined with "mute all others" selected
     and sb-send.c was present, it still received audio from sb-send.c
  C) The -m global mute option (added in last commit) was not needed

The investigation required reading two codebases:
  - /home/ground/src/sonobus-send/sb-send.c (the sender client)
  - /home/ground/src/sonobus (the real Sonobus, for protocol reference)

2. PROTOCOL ANALYSIS
--------------------

I explored the real Sonobus source code to understand the invite protocol.
Key findings from SonobusPluginProcessor.cpp and deps/aoo/lib/src/:

The invite flow in real Sonobus:
  1. Peer A joins group -> server broadcasts peer/join to existing members
  2. Existing Peer B creates RemotePeer for A
  3. Peer B calls oursink->invite_source(endpoint, 0, ...) which sends
     /aoo/src/0/invite to A's source
  4. A's source receives invite -> pushes AOO_INVITE_EVENT
  5. A's handler adds B as sink for A's source, then invites B back:
     peer->oursink->invite_source(es, peer->remoteSourceId, endpoint_send)
  6. Bidirectional audio is established

The mute mechanism:
  - "Mute all others" sets recvAllow=false via paramMainRecvMute
  - recvAllow=false prevents the real Sonobus from INITIATING invites
    (calling oursink->invite_source for that peer)
  - But the invite handler at line 3493 always processes incoming invites
    (if (true)) and always reciprocates (line 3543)
  - So a peer's invite BYPASSES the mute setting

Root cause identified:
  sb-send.c was sending /aoo/src/0/invite to peers. The real Sonobus
  always accepted and reciprocated, regardless of mute settings. This
  meant sb-send.c's audio got through even when "mute all others" was
  enabled.

3. FIRST ATTEMPT: REMOVE ALL INVITE SENDING
--------------------------------------------

My first approach was to remove ALL invite sending from sb-send.c.
The reasoning: if sb-send.c does not send invites, the real Sonobus
never reciprocates, and with "mute all others" the real Sonobus never
invites sb-send.c's source either. Clean separation.

Changes made:
  - Removed build_invite_msg function
  - Removed send_invite_to_peer function
  - Removed invite sending on peer join (process_tcp_message)
  - Removed invite retry in maintenance loop
  - Removed initial invites to existing peers in main()
  - Added /aoo/src/*/uninvite handler (new: stops sending when muted)
  - Removed -m option, SIGUSR1 handler, muted flag

This was committed as ee414ee.

4. SECOND ISSUE: PHANTOM /start HANDLER
-----------------------------------------

The user pointed out issue 6 from doc/issues.txt - the phantom
/aoo/src/<id>/start handler. Investigation showed:
  - SonoBus never sends /aoo/src/<id>/start messages
  - The handler set format_confirmed = true
  - format_confirmed was never read anywhere in the code
  - Dead code, safe to remove

Removed the handler and the unused format_confirmed field.
Committed as 271c232.

5. THIRD ISSUE: INITIAL CONNECTION BROKEN
------------------------------------------

After the first round of changes, the user reported: "mute works
great after you mute and unmute once. when sb-send first connects
even if mute all other is not set you must mute and unmute to get
audio."

This revealed a flaw in my reasoning. The investigation showed:

  - Real Sonobus does NOT automatically invite new peers' sources
    when it receives peer/join from the server
  - It waits for the peer to invite first, or for the user to
    manually connect (which calls invite_source)
  - Without sb-send.c's initial invite, the real Sonobus never
    invites sb-send.c's source
  - No invite from real Sonobus = sb-send.c never sends audio

The mute/unmute cycle works because:
  1. User toggles "mute all others" off
  2. setRemotePeerRecvActive(i, true) is called
  3. This calls oursink->invite_source() which sends the invite
  4. sb-send.c receives invite and starts sending

6. CORRECTED APPROACH: KEEP INVITES, ADD UNINVITE
---------------------------------------------------

The key insight: the UNINVITE handler is what makes mute work.
sb-send.c needs to send the initial invite to establish the
connection. The uninvite handler ensures that when the real
Sonobus mutes (uninvites), sb-send.c stops sending.

Re-added:
  - build_invite_msg function
  - send_invite_to_peer function
  - Invite sending on peer join
  - Invite retry in maintenance loop (INVITE_RETRY_INTERVAL)
  - Initial invites to existing peers in main()

Kept from first round:
  - /aoo/src/*/uninvite handler

Committed as b1b0510.

The resulting flow:
  1. sb-send.c joins, sends invite to real Sonobus
  2. Real Sonobus reciprocates, invites sb-send.c source back
  3. sb-send.c sends audio
  4. User mutes: real Sonobus uninvites sb-send.c, sb-send.c stops
  5. User unmutes: real Sonobus invites sb-send.c again, sb-send.c resumes

Remaining limitation (documented but not fixable in sb-send.c):
  - With "mute all others" enabled BEFORE sb-send.c joins, the initial
    connection still sends audio because the real Sonobus always
    reciprocates invites. The uninvite only happens after the initial
    connection is established and the user toggles mute. This is a
    real Sonobus protocol limitation.

7. STDIN ENCODING FEATURE
--------------------------

The user requested stdin encoding support with conditional ALSA
compilation.

Approach:
  - Added #define ALSA as a compiler flag (-DALSA) rather than
    hardcoded in source
  - Wrapped all ALSA code in #ifdef ALSA blocks
  - Added use_stdin flag to AppState
  - Added -a - option: when optarg is "-", set use_stdin=true
  - Audio thread: when use_stdin, reads s16le interleaved from
    stdin and converts to float; otherwise uses ALSA
  - Updated Makefile: default target builds with ALSA, "make
    nostdin" builds without
  - Updated help text with sox pipe example

Committed as c97614d.

8. THINGS TRIED AND REJECTED
------------------------------

a) Removing all invite sending entirely:
   Rejected because real Sonobus does not automatically invite new
   peers' sources. Without sb-send.c's invite, no audio flows.

b) Making sb-send.c wait passively for invites:
   Same problem as (a). The real Sonobus only invites when the user
   explicitly triggers it (mute/unmute, manual connect).

c) Sending a different handshake message:
   Considered whether sb-send.c could send a non-invite message to
   trigger the real Sonobus. Rejected: the real Sonobus only processes
   invites on its dummy source. No other message triggers the
   reciprocal invite.

d) Avoiding the real Sonobus adding sb-send.c as a sink:
   The real Sonobus's invite handler always calls
   oursource->add_sink() when it receives an invite. This starts
   sending audio TO sb-send.c (wasted bandwidth). There is no way
   to prevent this from sb-send.c's side. It is a protocol
   limitation.

e) Using recvAllow to prevent audio reception:
   Investigated whether recvAllow=false prevents the real Sonobus
   from receiving audio. Found that recvAllow only controls whether
   the real Sonobus INVITES sources, not whether it processes
   incoming audio data. The invite handler always reciprocates.

f) actor tool calls:
   Multiple attempts to use the actor tool for parallel exploration
   failed due to incorrect JSON schema format. The tool requires
   nested operation objects. Fell back to direct grep/read searches.

9. PROTOCOL GAPS REMAINING (from doc/issues.txt)
--------------------------------------------------

The following issues from the original issues.txt remain open:

  Issue 1:  Missing AOO_VERSION_BUGFIX in version encoding
  Issue 2:  Missing /aoo/server/ping (server keepalive)
  Issue 3:  Missing /aoo/src/<id>/ping response handling
  Issue 4:  Missing /aoo/src/<id>/data request (retransmission)
  Issue 7:  Missing /aoo/src/<id>/codec change handling
  Issue 8:  Local IP hardcoded to 0.0.0.0
  Issue 9:  Salt generation uses weak randomness
  Issue 10: No periodic peer ping sending
  Issue 11: Format request missing version argument read
  Issue 12: Invite handler ignores protocol flags
  Issue 13: Data message always sends nframes=1
  Issue 14: No handling of AOO_SOURCE_STATE_EVENT
  Issue 15: Ping response format verification
  Issue 16: Peer ping response (noted as correct)
  Issue 17: No handling of /sb/* custom messages
  Issue 18: Format message missing userformat blob
  Issue 19: No error handling for UDP send failures
  Issue 20: Missing /aoo/server/group/leave before disconnect

10. COMMITS IN THIS SESSION
----------------------------

  c97614d Add stdin encoding support with ALSA conditional compilation
  b1b0510 Re-add invite sending to trigger real Sonobus handshake
  271c232 Remove phantom /aoo/src/<id>/start handler (issue 6)
  ee414ee Fix mute bypass: sb-send.c now waits for peers to invite it

11. SECOND SESSION: COMPREHENSIVE REFACTOR
==========================================

Date: 2026-06-29 to 2026-07-09

This session covered a massive refactoring of sb-send, sb-rec, and the
build system. Changes span protocol fixes, code quality, new features,
multi-file split, Opus tuning, Windows support, and uninvite architecture.

12. RACE CONDITION FIXES
------------------------

a) server_thread maintenance loop peer pointer race:
   After pthread_mutex_unlock to send invite/ping, another thread
   could compact the peer list. After re-lock, pointer 'p' pointed
   to wrong or freed peer. Fixed by re-validating p by index i after
   each re-lock with bounds check.

b) Duplicate break statement:
   Line ~921 had two consecutive break statements after
   atomic_store(&state->running, false). Second was dead code. Removed.

13. SB-SEND CLIENT IDENTIFICATION (BIT 63 TOKEN)
-------------------------------------------------

Problem: sb-send instances were sending each other audio, wasting
bandwidth. Real SonoBus clients needed to be unaffected.

Solution: Use bit 63 of the client token as an sb-send identifier.

  - Real SonoBus tokens: uniform_int_distribution(1) -> range [1, INT64_MAX]
    bit 63 is always 0
  - sb-send tokens: ((int64_t)time(NULL) << 32) | getpid() | ((int64_t)1 << 63)
    bit 63 always set

Detection: When a ping arrives from an unknown address with bit 63 set
in the token, the peer is flagged is_sb_send=true. All UDP processing,
audio sending, invite sending, and maintenance pings skip these peers.

sb-rec uses a REAL SonoBus token (no bit 63) so it is NOT ignored
by sb-send clients.

14. OPUS ENCODER TUNING
-----------------------

a) ABR (Average Bitrate): Set OPUS_SET_VBR(1) + OPUS_SET_VBR_CONSTRAINT(1)
   for constrained VBR. The bitrate setting IS the average.

b) DTX (Discontinuous Transmission): OPUS_SET_DTX(1) reduces silence
   to near-zero bitrate. Requires OPUS_APPLICATION_VOIP or AUDIO
   (not RESTRICTED_LOWDELAY which disables LPC/Silk).

c) Application type: -V flag selects OPUS_APPLICATION_VOIP (2048),
   default uses OPUS_APPLICATION_AUDIO (2049). Both enable DTX.

d) Format message opus_opts blob updated to match actual encoder
   application type (was hardcoded to 2051 RESTRICTED_LOWDELAY).

e) Bitrate range: Min 500 bps, max 300000 bps per channel
   (from opus_encoder.c:2577-2582).

15. FRAME SIZE CONFIGURABLE (-F OPTION)
---------------------------------------

BLOCK_SIZE compile-time define eliminated entirely. Replaced by
state->frame_size runtime field in AppState.

  - -F ms accepts: 2.5, 5, 10, 20, 40 ms (validated against AOO
    rounding logic in codec_opus.cpp:93-97)
  - Default: 20ms (960 samples)
  - 60ms rejected: AOO rounds 2880 down to 1920 (40ms)
  - Stack arrays use VLAs with state->frame_size
  - audio-stdin.c uses fixed STDIN_CHUNK (480) since it only has
    ring buffer access, not AppState
  - Format message sends actual frame_size to receiver
  - Timing, ring size, prefill all independent of frame_size

16. RING BUFFER TIMING FIX
--------------------------

AMD64 Windows had terrible audio quality. Root cause: the audio
thread used SLEEP_MS(1) for pacing after reading from ring buffer.
Windows Sleep() has ~15ms granularity. For 10ms blocks (480 samples
at 48kHz), the thread slept 50% too long, causing time-stretched
garbled audio.

Fix: removed timing sleep from ring buffer path entirely. The ring
buffer's ring_get_float blocks naturally until data is available.
WASAPI fills at real-time rate via event callback, stdin fills from
pipe. The ring provides the pacing.

ARM64 Windows was unaffected because Sleep() has better granularity
on ARM64 Windows (likely 1ms due to modern SoC design).

17. COMMAND PARSER ROBUSTNESS
-----------------------------

a) --debug broken: getopt treated --debug as -- (end-of-options
   marker), stopping all option processing. Fixed by neutralizing
   --debug in argv (replacing with argv[0]) before getopt runs.

b) Missing argument detection: Leading ':' in optstring makes getopt
   return ':' for missing required args instead of '?'. Added case ':'
   handler with proper error message.

c) Unknown options: Added case '?' handler that prints the bad option
   character and exits with status 1.

d) Negative bitrate: atoi("-5") * 1000 = -5000 clamped to 500. Added
   explicit check for <= 0 with error message.

18. CODE QUALITY: BRACES AND TERNARIES
---------------------------------------

All if/else/for/while single-statement bodies given braces across
all source files. All ternary operators replaced with if/else blocks.
Prevents goto-fail style bugs. Verified by Python script scanning for
braceless control flow patterns and duplicate statements.

19. SB-RECEIVER PROGRAM (sb-rec)
--------------------------------

New program that receives and decodes audio from a single source.

  - Requires -N source nickname to listen to
  - Outputs raw s16le 48kHz to stdout (all status to stderr)
  - Uses real SonoBus token (no bit 63) so sb-send doesn't ignore it
  - Reads frame size from sender's format message
  - Only interacts with -N target peer; all others uninvited on
    any UDP contact
  - Exit 1 on TCP issues (called from scripts)
  - Links only protocol.o + peers.o (no audio.c/opus.c)

20. MULTI-FILE SPLIT
--------------------

Single sb-send.c (1971 lines) split into:

  sb-send.h   - shared header (types, defines, function declarations)
  sb-send.c   - main, args, signal handlers, connect/login, threads
  protocol.c  - OSC/SLIP, message builders/parsers, handle_udp_message,
                process_tcp_message, discover_public_ip
  peers.c     - PeerList management, send helpers
  audio.c     - ring buffer, audio_thread (generic interface)
  audio-alsa.c  - ALSA init/list
  audio-wasapi.c - WASAPI init/list/reader
  audio-stdin.c  - stdin reader (Win32+POSIX)
  opus.c      - init_opus
  sb-rec.c    - receiver program

Makefile: 8 build targets (all, stdin, sb-rec, sb-send-win64.exe,
sb-send-winarm64.exe, sb-rec-win64.exe, sb-rec-winarm64.exe, win).
Linux/Mac compile .o files then link. Windows compiles .c directly.

21. WINDOWS SUPPORT IMPROVEMENTS
---------------------------------

a) TCP keepalive: SO_KEEPALIVE + SIO_KEEPALIVE_VALS on Windows
   (30s idle, 10s interval). Linux: TCP_KEEPIDLE/INTVL/CNT.

b) Active SLIP_END probe: Every 5s in maintenance loop, send 1 byte
   to TCP socket. Forces TCP stack to attempt send, detecting
   half-open connections immediately.

c) WASAPI buffer: 5ms (reverted from 20ms after no improvement).
   Prefill: 100ms for WASAPI, 200ms for stdin.

d) Login message local IP: Uses public IP for both fields (was
   hardcoded 0.0.0.0). Simpler and works behind NAT.

22. MISCELLANEOUS FEATURES
--------------------------

a) -R receive bitrate: Tells peers what bitrate to use when sending
   to sb-send. sb-send doesn't decode incoming audio, so minimum
   quality saves bandwidth. Default same as -b.

b) -o raw audio output: Saves stdout, redirects fd 1 to stderr,
   writes raw s16le 48kHz before opus encode. POSIX only.

c) --version compile date: COMPILE_DATE/COMPILE_TIME macros.

d) SLEEP_MS macro: Replaces all platform #ifdef Sleep/usleep blocks.

e) SB_ASSERT macro: Prints file:line and abort() on failure.

f) Debug output with --debug: dbg() macro in sb-send.h. Prints to
   stderr. Added to protocol.c (message dispatch), audio.c (encode
   failures), sb-send.c (maintenance loop stats).

g) assert pattern scan: Python script checked all .c files for
   braceless if patterns and dead assignments. No goto-fail bugs found.

23. UNINVITE ARCHITECTURE REFACTOR
-----------------------------------

The uninvite mechanism went through several iterations:

a) First attempt: Remove all invite sending. FAILED — real Sonobus
   never invites without sb-send initiating first.

b) Second attempt: Keep invites, add uninvite handler for source_id=0.
   Partially worked but source_id=0 uninvites don't stop the real
   source (Sonobus assigns a different source_id).

c) Third attempt: Learn real source_id from format messages, uninvite
   with that. Works for peers that send format, but some peers
   (like gfcmac) never send format messages.

d) Fourth attempt: Parse source_id from invite address
   (/aoo/src/<id>/invite). Works immediately on connect without
   waiting for format. Also handle /d compact data as fallback.

Final architecture:

  1. Invite handler: Parse their_source_id from invite address,
     store in p->remote_source_id, send format + uninvite immediately

  2. Format handler: Update remote_source_id if different, send
     uninvite immediately

  3. /d compact data handler: If remote_source_id still unknown (-1),
     infer from p->our_source_id, send uninvite

  4. run_maintenance() function (called every 5s from server_thread):
     - Invite retry for unconnected peers
     - Source pings for connected peers
     - Timeout detection
     - Peer list compaction
     - New peer uninvite: any connected peer with remote_source_id >= 0
       and !uninvited gets uninvited, then uninvited = true
     - Periodic uninvite: every 300s, all connected peers with known
       source_id get uninvited again

  5. send_uninvite_to_peer() in peers.c: Guards against our_source_id
     == 0, prints every uninvite to stderr

Peer struct additions:
  - bool uninvited: Tracks whether initial uninvite sent. Reset on
    reconnect (invite handler) or new format (format handler).
  - int32_t remote_source_id: Initialized to -1 (unknown). Set from
    invite address (atoi), format message, or /d fallback.
  - int64_t remote_source_id: Stored from peer's format message.

24. BUILD TARGETS (CURRENT STATE)
---------------------------------

  make all       - sb-send + sb-rec (ALSA, Linux)
  make stdin     - sb-send + sb-rec (no ALSA, macOS/stdin)
  make sb-rec    - just sb-rec (ALSA)
  make win       - sb-send-win64 + sb-rec-win64 + sb-send-winarm64 + sb-rec-winarm64

All 8 targets verified clean on every code change throughout session.

25. THIRD SESSION: OGG CONTAINER AND OPUS PACKET INVESTIGATION
================================================================

Date: 2026-07-11 to 2026-07-12
Focus: opus2ogg.c, sb-rec.c -o, signal handling, libogg dependency

26. PROBLEM: RAW OPUS IS NOT PLAYABLE
--------------------------------------

sb-rec -r writes length-prefixed raw opus packets to stdout.
These packets are not decodable by any standard player. opusdec,
opusplay, ffplay -- none of them accept raw opus bitstreams.
The packets need an Ogg container wrapper.

New tool: opus2ogg.c
  - Reads length-prefixed raw opus from stdin
  - Writes Ogg/Opus container to stdout
  - Usage: sb-rec -r -N source | opus2ogg -c 1 > output.ogg
  - Links against libogg (not libopus)
  - No re-encoding -- wraps raw packets in Ogg pages

27. HAND-ROLLED OGG PAGES (FAILED)
------------------------------------

First attempt: custom CRC32, custom page header construction,
custom segment table logic. Verified CRC byte-for-byte against
libogg output. Pages were structurally correct.

Result: libogg ogg_sync_pageseek rejected every page. No error
message, no diagnostic. The parser simply found nothing.

Root cause never identified. CRC matched. Page structure matched.
Possible alignment or state machine issue in the parser that is
invisible to byte-level inspection.

Abandoned hand-rolled approach. Rewrote to use libogg API
(ogg_stream_init, ogg_stream_packetin, ogg_stream_flush,
ogg_stream_pageout). Worked immediately.

Lesson: libogg is ~300 lines of code. Writing your own defeats
the purpose.

28. OPUS PACKET DECODE INVESTIGATION
--------------------------------------

Both test.opus (772KB) and test1.opusraw (255KB) failed to decode
with standard libopus. opus_decode_float() and
opus_multistream_decode_float() both returned "buffer too small"
for every packet.

TOC analysis showed: config=15, stereo=0, frames=1 or 2.
Config 15 in the Opus spec = CELT fullband at 20ms (960
samples/frame). The packets looked valid.

Initial conclusion: sb-rec's opus packets are not decodable.
Suspected encoder format mismatch.

Actual cause: test decoder used frame_size=480 (10ms) but packets
contain 20ms frames. Config 15 = 960 samples per frame. Two-frame
packets need frame_size=1920. The "buffer too small" error was
literally the buffer being too small.

Fixed test: frame_size=1920, all 2014 packets decoded. Zero
failures.

Lesson: when a decoder says "buffer too small," try a bigger
buffer before concluding the encoder is broken.

29. TOC FRAME COUNT MISTAKE
-----------------------------

Opus TOC byte frame count codes:
  0 = 1 frame
  1 = 2 frames of equal size
  2 = 2 frames of different size
  3 = arbitrary number of frames

Initial opus2ogg code: get_opus_frame_count returns (toc & 3) + 1
  - Code 0 -> 1 (correct)
  - Code 1 -> 2 (correct)
  - Code 2 -> 3 (WRONG -- should be 2)
  - Code 3 -> 4 (WRONG -- needs segment table parsing)

test1.opusraw breakdown:
  - Code 1 (2 frames, equal): 205 packets
  - Code 2 (2 frames, different): 1809 packets

Result: 1809 packets overcounted by 1 frame each.
Total error: 1809 * 960 = 1,736,640 extra samples.
opusinfo showed: "Sample count behind granule" for entire file.

Fix: get_opus_frame_count now returns 2 for both codes 1 and 2,
and parses segment table for code 3.

30. FRAME SIZE FROM TOC CONFIG
-------------------------------

Fixed opus2ogg to derive samples-per-frame from the TOC config
number rather than hardcoding 960:

  config & 3 -> samples
  0          -> 120 (2.5ms)
  1          -> 240 (5ms)
  2          -> 480 (10ms)
  3          -> 960 (20ms)

This means opus2ogg now works with any Opus frame size, including
sb-send -F 10 (10ms = 480 samples/frame). The TOC byte tells
you everything you need.

31. DUPLICATE HEADER BUG
--------------------------

First working version of opus2ogg.c wrote OpusHead and OpusTags
TWICE -- once at lines 92-93 (packetin only), then again at
lines 97-107 (packetin + flush). The first pair sat in libogg's
internal buffer, merged with data packets.

Result: opusinfo showed only 2 initial pages (OpusHead + merged
data). No separate OpusTags page. opusdec rejected the file.

Fix: removed the duplicate writes. Each header is written once
and flushed immediately with ogg_stream_flush (not pageout).

32. EOS MARKING
----------------

Last packet in the stream needs e_o_s = 1 in the Ogg packet.
Without it, opusdec cannot determine the stream end.

Initial approach: set e_o_s = 1 on ALL packets. This caused
"ERROR: stream 1 has a negative duration" in opusinfo.

Fix: buffer the last packet, write it with e_o_s = 1 after the
read loop ends. This added ~4KB of memory (one packet buffer)
but ensures correct EOS at the exact right position.

33. SB-REC -o FLAG: DIRECT OGG OUTPUT
---------------------------------------

New flag added to sb-rec: -o writes Ogg/Opus container directly
to stdout. No decode, no re-encode. The opus packets from the
network go straight into Ogg pages.

  sb-rec -o -N source > output.ogg

Architecture:
  - ogg_init_stream(channels): Writes OpusHead + OpusTags headers,
    initializes ogg_stream_state with random serial number
  - ogg_write_packet(data, len): Computes granule from TOC byte,
    buffers last packet for EOS marking, packetin + pageout
  - ogg_finish(): Writes EOS on last packet, flushes remaining
    pages, clears ogg_stream_state

The Ogg state is global (g_ogg_os, g_ogg_granule, etc.) because
packets arrive in the UDP thread but finalization happens in main()
after thread join.

Channels are learned from the format message (dec_channels), not
from command line. The Ogg stream is initialized on first packet,
not on startup, because channels are unknown until the sender's
format arrives.

Mutual exclusion: -r and -o cannot be used together.

34. SIGNAL HANDLING FOR CLEAN SHUTDOWN
----------------------------------------

Problem: Ctrl-C on sb-rec kills the process immediately. If -o is
active, the Ogg file is truncated -- no EOS, no final page. Most
players will not play a truncated Ogg file.

Solution:
  1. Signal handler sets g_running = 0 (async-signal-safe)
  2. UDP thread's select() returns EINTR, thread exits
  3. Server thread's select() returns EINTR, thread exits
  4. Main loop exits, pthread_join waits for both threads
  5. ogg_finish() writes EOS on last buffered packet
  6. Remaining Ogg pages are flushed to stdout

This is safe because ogg_stream functions are NOT async-signal-
safe. The signal handler only sets a flag. All Ogg I/O happens
in main() after threads have drained.

Same treatment applied to opus2ogg.c:
  - SIGINT/SIGTERM handler sets g_running = 0
  - Read loop checks g_running, exits cleanly
  - Last packet gets EOS, pages flushed

35. LIBOGG DEPENDENCY
----------------------

libogg added to sb-rec build:
  - Linux: -logg (system package libogg-dev)
  - Windows: static libogg.a built from source

libogg source: libogg-1.3.5 from xiph.org
  - Two C files: src/framing.c, src/bitwise.c
  - No external dependencies
  - Cross-compiled for Win64 and WinARM64 with llvm-mingw

Static libraries built at:
  /home/ground/tmp/libogg-win64/libogg.a
  /home/ground/tmp/libogg-winarm64/libogg.a

Makefile updated:
  - sb-rec links -lopus -logg on Linux
  - sb-rec-win64.exe links libopus.a + libogg.a
  - sb-rec-winarm64.exe links libopus.a + libogg.a
  - Include path: libogg-1.3.5/include

36. BUILD TARGETS (CURRENT STATE)
-----------------------------------

  make all       - sb-send + sb-rec + opus2ogg (ALSA, Linux)
  make stdin     - sb-send-nalsa + sb-rec + opus2ogg (no ALSA)
  make win       - all four Windows executables (no opus2ogg on Windows)

All 10 targets verified clean: ALSA, stdin, Win64, WinARM64.

37. SESSION COMMITS
--------------------

  60f0950 Bump version. Release party: Aug 1st 2026 00:00 UTC.
  0cd8f25 Try to make Makefile better. -logg for sb-rec, worth it.
  41309a6 sb-rec.c: Add -o option to write ogg to stdout.
  9c12f12 opus2ogg.c: add signal handler. write final bytes.
  9218f18 opus2ogg.c: Handle all opus frame sizes.
  86a997a opus2ogg.c: Fixes so we can decode the files.
  2163779 Added opus2ogg.c to convert raw opus to ogg container.
