Files
node-26/drb-edge-node/tests/test_audio_trim.py
T
Logan Cusano b0a8ed2a5a
CI / lint (push) Failing after 4s
CI / test (push) Successful in 21s
Fix tail truncation, decouple call length from buffer, trim silence
Measured six recordings off a live P25 node and found two independent
defects causing lost audio at the end of calls:

- stop_recording() sliced the ring buffer immediately, so if the MP3
  muxer had not yet delivered the tail the file was silently short.
  Now waits (bounded, 2s) until buffered audio covers the end epoch.
- TGID-change closes used the new grant's epoch as the end with no pad
  at all, guaranteeing truncation on every split. Tail pad is now a
  setting, default raised 0.5s -> 1.0s.

The ring buffer also capped maximum call length: a call longer than the
buffer had its front silently clamped. The ring now serves the pre-roll
only, with a per-call accumulator for the rest, bounded at 4.8MB.
Clamping is loudly warned rather than silent.

Uploads averaged 63% silence, which inflates STT cost and is a known
Whisper hallucination trigger. Leading/trailing silence is now trimmed
conservatively (-40dB, 0.25s guard, internal pauses untouched).
started_at/ended_at still describe the call; new audio_* fields carry
the trimmed audio bounds so playback can map back to wall clock.
All-silence recordings are skipped and logged instead of uploaded.

Also: log measured control-channel idle on idle-timeout closes so
CALL_IDLE_TIMEOUT can be tuned from data, and quiet the httpx logger
which emitted ~170k lines/day of poll noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:39:36 -04:00

161 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for silence-trim decision logic.
`speech_bounds` is pure on purpose so the "what do we keep" decision — the part
that can destroy a transmission if it is wrong — is testable without FFmpeg.
The numbers below come from ffmpeg silencedetect run against six real recordings
off a live P25 node: 1.712.45 s of leading silence and 0.001.11 s trailing.
"""
import pytest
from app.internal.audio_trim import (
TrimResult,
_parse_duration,
_parse_silences,
speech_bounds,
)
GUARD = 0.25
def test_leading_silence_is_trimmed_with_a_guard_margin():
# Real shape of file f4bfaa1f: 1.85s lead, 0.34s trail, 4.54s total.
regions = [(0.0, 1.85), (4.20, None)]
start, end, all_silence = speech_bounds(regions, duration=4.54, guard=GUARD)
assert not all_silence
assert start == pytest.approx(1.85 - GUARD)
assert end == pytest.approx(4.20 + GUARD)
# The guard must never eat into detected speech.
assert start < 1.85 and end > 4.20
def test_guard_margin_never_runs_past_the_file_bounds():
regions = [(0.0, 0.10), (3.95, None)]
start, end, _ = speech_bounds(regions, duration=4.0, guard=1.0)
assert start == 0.0
assert end == 4.0
def test_trailing_silence_is_trimmed_when_ffmpeg_closes_the_region_at_eof():
"""
FFmpeg 6.x flushes a `silence_end` at EOF, so a trailing region looks closed.
Treating "no silence_end" as the only trailing signal silently disabled tail
trimming entirely — verified against ffmpeg 6.1.1.
"""
# Real ffmpeg 6.1.1 output for a 2s-silence + 1.5s-tone + 1s-silence file.
regions = [(0.0, 2.05361), (3.56367, 4.63102)]
start, end, all_silence = speech_bounds(regions, duration=4.65, guard=GUARD)
assert not all_silence
assert start == pytest.approx(2.05361 - GUARD)
assert end == pytest.approx(3.56367 + GUARD), "the trailing second must be trimmed"
def test_all_silence_survives_ffmpeg_closing_the_region_at_eof():
# Real ffmpeg 6.1.1 output for a 4s file of pure silence.
_, _, all_silence = speech_bounds([(0.0, 4.0)], duration=4.03, guard=GUARD)
assert all_silence
def test_trailing_silence_that_does_not_reach_eof_is_left_alone():
"""
A silence region with a closing silence_end is an internal pause between
transmissions, not dead air at the tail. Trimming it would cut the middle
out of a conversation.
"""
regions = [(0.0, 1.9), (5.0, 7.5)]
start, end, _ = speech_bounds(regions, duration=12.0, guard=GUARD)
assert start == pytest.approx(1.9 - GUARD)
assert end == 12.0, "an internal pause must not shorten the file"
def test_no_silence_detected_keeps_the_whole_file():
start, end, all_silence = speech_bounds([], duration=6.0, guard=GUARD)
assert (start, end) == (0.0, 6.0)
assert not all_silence
def test_silence_starting_late_is_not_treated_as_leading():
"""Only a region at the very head counts as leading silence."""
regions = [(1.20, 2.00)]
start, end, _ = speech_bounds(regions, duration=5.0, guard=GUARD)
assert start == 0.0, "speech before 1.20s must not be trimmed away"
assert end == 5.0
def test_all_silence_is_reported_not_trimmed_to_nothing():
# One region covering the whole file and running to EOF.
regions = [(0.0, None)]
start, end, all_silence = speech_bounds(regions, duration=4.0, guard=GUARD)
assert all_silence
assert (start, end) == (0.0, 4.0), "an all-silence file must not become zero-length"
def test_all_silence_when_head_and_tail_regions_overlap():
regions = [(0.0, 3.2), (3.0, None)]
_, _, all_silence = speech_bounds(regions, duration=4.0, guard=GUARD)
assert all_silence
# ---------------------------------------------------------------------------
# FFmpeg output parsing
# ---------------------------------------------------------------------------
# Verbatim shape of ffmpeg 6.1.1 output.
FFMPEG_STDERR = """
Input #0, mp3, from '/recordings/x.mp3':
Duration: 00:00:04.70, start: 0.050113, bitrate: 16 kb/s
[silencedetect @ 0000029160e63f40] silence_start: 0
[silencedetect @ 0000029160e63f40] silence_end: 2.05361 | silence_duration: 2.05361
[silencedetect @ 0000029160e63f40] silence_start: 3.56367
[silencedetect @ 0000029160e63f40] silence_end: 4.63102 | silence_duration: 1.06735
[out#0/null @ 0x2] video:0kB audio:97kB
"""
FFMPEG_STDERR_OPEN_TAIL = """
Duration: 00:00:04.54, start: 0.000000, bitrate: 16 kb/s
[silencedetect @ 0x1] silence_start: 0
[silencedetect @ 0x1] silence_end: 1.85042 | silence_duration: 1.85042
[silencedetect @ 0x1] silence_start: 4.20134
"""
def test_duration_is_corrected_for_the_mp3_container_start_offset():
"""
MP3 encoder delay makes the container duration longer than the audio
silencedetect timestamps. Without this correction the trailing-region test
needs a slack epsilon big enough to clip real speech.
"""
assert _parse_duration(FFMPEG_STDERR) == pytest.approx(4.70 - 0.050113)
def test_duration_is_none_when_absent():
assert _parse_duration("no duration here") is None
def test_silence_regions_are_parsed():
regions = _parse_silences(FFMPEG_STDERR)
assert len(regions) == 2
assert regions[0] == (pytest.approx(0.0), pytest.approx(2.05361))
assert regions[1] == (pytest.approx(3.56367), pytest.approx(4.63102))
def test_a_region_with_no_silence_end_is_still_parsed():
"""Older FFmpeg simply stopped reporting at EOF — keep handling that."""
regions = _parse_silences(FFMPEG_STDERR_OPEN_TAIL)
assert regions[-1][1] is None
def test_trim_result_reports_total_trimmed():
result = TrimResult(path=None, lead=1.9, tail=0.35)
assert result.trimmed_seconds == pytest.approx(2.25)