Fix SRS transmissions being split into fragments#712
Draft
dharmab wants to merge 1 commit into
Draft
Conversation
The SRS protocol has no end-of-transmission marker, so the receiver infers transmission boundaries from a silence gap. Three bugs in that logic split a single utterance into fragments, which reach whisper as garbled or duplicated requests, or fall under the one second minimum and are discarded with no log line explaining why. The channel now owns the transmission window rather than the caller. A window opens when a packet arrives on an idle channel and closes when no packet has arrived for the grace period. Each receiver's window is evaluated on its own, the way a GCI monitoring several frequencies hears each one separately. Within a window the first transmitter wins and anyone stepping on them is dropped, mirroring radio capture effect and keeping the buffer single-origin for the Opus decoder. Fixed: - Publishing a finished transmission was gated on the shared inbound queue being empty, so traffic on any frequency - including ones no radio is tuned to - could hold another channel's transmission indefinitely. The tick now drains the queue and then evaluates each deadline independently. - "In progress" meant "state has not been reset" rather than "the deadline has not passed", so a finished but unpublished transmission locked out the next caller. Completed transmissions are now published before each packet is buffered, so a new caller opens a window the instant the previous one expires. - Packet numbers were reset to zero on every publish. SRS numbers climb for the life of a client, so a packet delayed past the end of its own transmission was mistaken for the start of a new one: it buffered a stale frame, armed a fresh deadline, and blocked the next real caller. The high-water mark is now kept per transmitter across transmissions. Confirmed against a live server: two transmissions 400 seconds apart carried consecutive packet numbers. Since relying on that could permanently silence a client that restarted its numbering, a backwards packet number only counts as stale within twice the grace period of that transmitter's last packet. Also: - Expose the grace period as --srs-split-transmission-grace-period, default unchanged at 300ms. Measured against a live server, packets arrive 40ms apart with a worst case of 52ms, so the default has plenty of headroom. - voice.Decode trusted the length header over the datagram, so a corrupt packet could decode into a plausible packet with a garbage origin GUID and hold a frequency for a full grace period. It now validates the framing. - Log the wall clock span alongside the audio duration, so a transmission discarded for being short can be told apart from one shortened by packet loss. - Take the receiver lock for every access. waitForClearChannel read a deadline and the publish path read the buffer without it, and the coalition filter read the clients map without clientsLock. - Reduce the inbound voice channel from 64*0xFFFFF to a bounded size. It reserved roughly 1.6GB of slice headers and let backlogs grow without limit. Two unrelated bugs found while working here, called out for review: - --mute never reached the SRS client, so the flag did nothing. - RadioFrequency.String() had a transposed format verb and printed hertz, so 251MHz AM rendered as "251000000.000000.3AM". Nothing calls it today. The package had almost no tests, so this adds a baseline suite alongside the regression tests: packet encode/decode round trips and malformed input, frequency and radio matching, client sync filtering, server settings, message JSON against a captured wire sample, and the Opus frame round trip. Time dependent tests use testing/synctest, matching pkg/controller. Enables -race for the test suite, which the whole tree passes in about 16 seconds. Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The SRS protocol has no end-of-transmission marker, so
pkg/simpleradio/receive.goinfers transmission boundaries from a silence gap. Three bugs in that logic split a single utterance into fragments, which reach whisper as garbled or duplicated requests, or fall underminRxDuration(1s) and get discarded with no log line explaining why.The package had essentially no tests — nothing referenced
receiver,maxRxGap,hasTransmission, orreceiveVoice— so this also adds a baseline suite.Design
The channel owns the transmission window, not the caller. A window opens when a packet arrives on an idle channel and closes when no packet has arrived for the grace period. Each receiver's window is evaluated independently, the way a GCI monitoring several frequencies hears each one separately. Within a window the first transmitter wins and anyone stepping on them is dropped — radio capture effect, and it keeps the buffer single-origin for the Opus decoder.
The three splitting bugs
len(in) == 0, so traffic on any frequency — including ones no radio is tuned to — could hold another channel's transmission indefinitelyVerified against a live server
Captured 1180 packets from
eu.limakilo.net(receive only, never transmitted):6+31+40+58 = 135= datagram size, confirming the newvoice.Decodevalidation doesn't reject real traffic.PacketID <= highWaterMarkforever and be silenced permanently — strictly worse than the stale packet the check exists to reject, and something the old code tolerated by accident. A backwards packet number now only counts as stale within 2× the grace period of that transmitter's last packet. Test:TestReceiverAcceptsRenumberedTransmitter.Also in here
--srs-split-transmission-grace-period, default unchanged at 300msvoice.Decodevalidates framing — it trusted the length header over the datagram, so a corrupt packet could decode with a garbage origin GUID and hold a frequencywaitForClearChannelread a deadline unlocked, publish read the buffer unlocked, coalition filter readclientswithoutclientsLock)64*0xFFFFF(~1.6GB of slice headers, unbounded backlog) to a bounded size🔍 Two unrelated bugs — please look at these separately
--mutewas a no-op.cmd/skyeye/main.gosetconf.Configuration.Mute, butapp.gonever passed it intosrs.ClientConfiguration. Happy to split into its own PR.RadioFrequency.String()had a transposed verb ("%f.3%s") and printed hertz, so 251MHz AM rendered as251000000.000000.3AM. Latent — nothing calls it.go vetmisses it because%fis valid for a float64 and.3reads as literal text.Testing
Baseline suite added: packet encode/decode round trips and malformed input, frequency/radio matching, client sync filtering, server settings, message JSON against a captured (scrubbed) wire sample, Opus frame round trip. Time-dependent tests use
testing/synctest, matchingpkg/controller.-raceis now on for the test suite — the whole tree passes in ~16s, so it didn't need scoping to one package.Mutation-checked the regression tests by reverting each fix. One caveat: the
breakafter the first frequency match is now dead defense — the per-transmitter dedup already rejects the repeat, so removing it fails nothing. Kept it to avoid a redundant locked call, but it isn't load-bearing.Not verified: whether the SRS server filters voice packets by frequency server-side, which is what sets bug 1's real-world severity. The controlled capture on a bogus frequency didn't happen, and the observed packets listed four frequencies I was partly tuned to, so they prove nothing either way. The fix is structural — publication no longer consults queue depth at all — so severity doesn't change the code.
🤖 Generated with Claude Code