diff --git a/mumble-bridge/README.md b/mumble-bridge/README.md index b64512d..d4ffb96 100644 --- a/mumble-bridge/README.md +++ b/mumble-bridge/README.md @@ -45,6 +45,8 @@ Deploy one container per EVS client so every device appears as its own Mumble us - `VAD_CLOSE_FRAMES`: consecutive silence frames to close gate (default `20`) - `HPF_ENABLED`: enable high-pass/DC blocker to reduce low-frequency rumble (default `true`) - `HPF_CUTOFF_HZ`: high-pass cutoff frequency (default `120.0`) +- `TX_BUFFER_MAX_MS`: maximum bridge TX buffer before dropping old audio to stop latency growth (default `800`) +- `TX_BUFFER_TARGET_MS`: buffer target after drop (default `300`) ## Example docker compose service diff --git a/mumble-bridge/app.py b/mumble-bridge/app.py index 7eeae0d..99d45ba 100644 --- a/mumble-bridge/app.py +++ b/mumble-bridge/app.py @@ -58,6 +58,8 @@ VAD_OPEN_FRAMES = int(os.getenv("VAD_OPEN_FRAMES", "2")) VAD_CLOSE_FRAMES = int(os.getenv("VAD_CLOSE_FRAMES", "20")) HPF_ENABLED = getenv_bool("HPF_ENABLED", True) HPF_CUTOFF_HZ = float(os.getenv("HPF_CUTOFF_HZ", "120.0")) +TX_BUFFER_MAX_MS = int(os.getenv("TX_BUFFER_MAX_MS", "800")) +TX_BUFFER_TARGET_MS = int(os.getenv("TX_BUFFER_TARGET_MS", "300")) def _channel_name(ch) -> str: @@ -456,10 +458,14 @@ def run() -> None: hp_prev_y = 0.0 out_buffer = bytearray() frame_bytes = int((MUMBLE_SAMPLE_RATE * 2 * FRAME_MS) / 1000) # mono, 16-bit + frame_period_s = FRAME_MS / 1000.0 + next_send_ts = time.monotonic() udp_packets = 0 udp_bytes = 0 frames_sent = 0 send_errors = 0 + dropped_bytes = 0 + dropped_frames = 0 vad_dropped_packets = 0 vad_open = not VAD_ENABLED vad_voice_frames = 0 @@ -476,6 +482,7 @@ def run() -> None: rate_state = None hp_prev_x = 0.0 hp_prev_y = 0.0 + next_send_ts = time.monotonic() except Exception: log.exception("mumble connect failed, retrying in %ss", MUMBLE_RECONNECT_SEC) time.sleep(MUMBLE_RECONNECT_SEC) @@ -494,13 +501,15 @@ def run() -> None: if BRIDGE_STATS_INTERVAL_SEC > 0 and (now - stats_t0) >= BRIDGE_STATS_INTERVAL_SEC: dt = max(0.001, now - stats_t0) log.info( - "bridge stats: udp_packets=%d udp_bytes=%d udp_kbps=%.1f frames_sent=%d send_errors=%d buffer_bytes=%d vad_open=%s vad_dropped=%d last_from=%s", + "bridge stats: udp_packets=%d udp_bytes=%d udp_kbps=%.1f frames_sent=%d send_errors=%d buffer_bytes=%d dropped_frames=%d dropped_bytes=%d vad_open=%s vad_dropped=%d last_from=%s", udp_packets, udp_bytes, (udp_bytes * 8.0 / 1000.0) / dt, frames_sent, send_errors, len(out_buffer), + dropped_frames, + dropped_bytes, vad_open, vad_dropped_packets, last_udp_from or "-", @@ -509,6 +518,8 @@ def run() -> None: udp_bytes = 0 frames_sent = 0 send_errors = 0 + dropped_bytes = 0 + dropped_frames = 0 vad_dropped_packets = 0 stats_t0 = now @@ -550,11 +561,30 @@ def run() -> None: ) out_buffer.extend(processed) - while len(out_buffer) >= frame_bytes: + if TX_BUFFER_MAX_MS > 0: + max_bytes = int((MUMBLE_SAMPLE_RATE * 2 * TX_BUFFER_MAX_MS) / 1000) + target_bytes = int((MUMBLE_SAMPLE_RATE * 2 * TX_BUFFER_TARGET_MS) / 1000) + if target_bytes < frame_bytes: + target_bytes = frame_bytes + if len(out_buffer) > max_bytes: + to_drop = len(out_buffer) - target_bytes + # Keep alignment at 16-bit boundaries. + to_drop -= to_drop % 2 + if to_drop > 0: + del out_buffer[:to_drop] + dropped_bytes += to_drop + dropped_frames += to_drop // frame_bytes + + while len(out_buffer) >= frame_bytes and time.monotonic() >= next_send_ts: frame = bytes(out_buffer[:frame_bytes]) del out_buffer[:frame_bytes] mumble.sound_output.add_sound(frame) frames_sent += 1 + next_send_ts += frame_period_s + # Prevent "runaway catchup" if scheduler was stalled. + now_mono = time.monotonic() + if (now_mono - next_send_ts) > (frame_period_s * 5): + next_send_ts = now_mono except Exception: send_errors += 1 log.exception("audio processing/send failed")