Skip to content

fix(transfer): flush partial bridge PCM frame on clean WebSocket close - #275

Merged
shenjinti merged 1 commit into
restsend:mainfrom
dengxiayehu:fix/bridge_partial_frame
Sep 7, 2026
Merged

fix(transfer): flush partial bridge PCM frame on clean WebSocket close#275
shenjinti merged 1 commit into
restsend:mainfrom
dengxiayehu:fix/bridge_partial_frame

Conversation

@dengxiayehu

Copy link
Copy Markdown
Contributor

Problem

In SipSession::connect_bridge, the bridge forward loop reads PCM16 audio
from the bridge WebSocket and repackages it into fixed 20 ms frames before
forwarding to its sink:

  • Pcm sink: samples pushed to a PCM channel
  • Track sink: samples (re)sampler → codec encoder → RTC track as RTP frames

The loop only emitted a frame when the buffer held at least
samples_per_frame samples. When the remote side closed the WebSocket
cleanly (Message::Close or stream EOF), any buffered samples shorter than
one frame — up to 20 ms of audio — were silently discarded, because no code
path ever drained a partial buffer.

So the tail of bridge audio was truncated at the end of every cleanly closed
bridge stream. For example, an audio service that streams a prompt over the
bridge and then closes the socket loses the final few milliseconds of the
prompt on the SIP leg.

Fix

  • Extract the forward loop into a standalone generic
    bridge_forward_loop<S: Stream<Item = Result<Message, Error>>>. This is a
    behavior-preserving move: it makes the close→flush wiring testable at its
    real boundary with an in-memory stream instead of a live WebSocket.
  • Add take_bridge_pcm_frame(buffered, samples_per_frame, flush_tail) as the
    single frame-extraction point, called before sink dispatch. When the stream
    ends cleanly and a non-empty partial frame remains, it zero-pads the tail
    to exactly one full frame and emits it once. Since frame extraction is now
    shared, both sinks benefit: the Pcm sink receives the padded tail frame,
    and the Track path encodes it into a final RTP frame.
  • A clean close (Message::Close / EOF) is the only signal that a short
    chunk is final. Deliberate boundaries, unchanged from before:
    • a WebSocket read error still terminates the loop without flushing,
      since the buffered data may be incomplete/corrupt;
    • cancellation via forward_cancel still discards the buffer.

Tests

  • test_bridge_forward_flushes_pcm_tail_on_remote_close: feeds 40 samples
    (5 ms at 8 kHz — short of one 20 ms frame) followed by Message::Close
    into bridge_forward_loop with a Pcm sink. Asserts the loop terminates
    on remote close and flushes exactly one 160-sample frame containing the
    original samples followed by zero padding.
  • Renamed/extended test_voip_bridge_echo_integration
    test_voip_bridge_flushes_partial_pcm_tail: sends 25 ms (200 samples)
    through a real echo WebSocket server so that one full frame plus a 5 ms
    tail remains at clean close, then drives the flushed frames through the
    real ChannelAudioSource used by the media bridge egress path and asserts
    the full frame, the padded tail, and subsequent EOF.

- pad and flush the buffered PCM tail as a final frame when the bridge
  WebSocket closes cleanly, for both PCM and track forward sinks
- extract the bridge forward loop into bridge_forward_loop so the
  close-to-flush path is exercised at its real boundary by tests
- add unit test asserting the zero-padded tail frame is flushed once
@shenjinti
shenjinti merged commit 79d3455 into restsend:main Sep 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants