Move recording and Discord voice to PulseAudio
CI / lint (push) Failing after 26s
CI / test (push) Successful in 41s

This commit is contained in:
Logan Cusano
2026-08-04 19:53:45 -04:00
parent 9addce7716
commit efdbe7d803
11 changed files with 1382 additions and 270 deletions
+203
View File
@@ -0,0 +1,203 @@
"""
Unit tests for the CallRecorder ring buffer and per-call slicing.
No FFmpeg and no PulseAudio: the buffer is filled directly with timestamped
chunks, which is exactly what _ingest() produces at runtime.
"""
import time
from unittest.mock import patch
import pytest
from app.internal.call_recorder import (
CallRecorder,
MAX_RECORDING_SECONDS,
PRE_ROLL_SECONDS,
RING_BUFFER_SECONDS,
)
T0 = 1_700_000_000.0
CHUNK_INTERVAL = 0.1 # seconds of audio per synthetic chunk
@pytest.fixture
def recorder(tmp_path):
r = CallRecorder()
r._recordings_dir = tmp_path
r._capturing = True
return r
def fill(recorder, start: float, end: float, marker: bytes = b"A"):
"""Append one chunk every CHUNK_INTERVAL seconds over [start, end)."""
ts = start
index = 0
while ts < end:
recorder._buffer.append((ts, marker + str(index).encode() + b";"))
index += 1
ts = round(ts + CHUNK_INTERVAL, 6)
def timestamps(recorder):
return [ts for ts, _ in recorder._buffer]
# ---------------------------------------------------------------------------
# Ring buffer trimming
# ---------------------------------------------------------------------------
def test_idle_buffer_keeps_only_the_rolling_window(recorder):
with patch("app.internal.call_recorder.time.time", return_value=T0):
for offset in range(0, int(RING_BUFFER_SECONDS) + 20):
with patch("app.internal.call_recorder.time.time", return_value=T0 + offset):
recorder._ingest(b"x" * 16)
assert len(recorder._buffer) <= RING_BUFFER_SECONDS + 1
assert min(timestamps(recorder)) >= (T0 + RING_BUFFER_SECONDS + 19) - RING_BUFFER_SECONDS
@pytest.mark.asyncio
async def test_buffer_is_not_trimmed_below_the_active_slice(recorder):
fill(recorder, T0, T0 + 5.0)
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
# Ingest far past the normal rolling window; the slice start must survive.
with patch("app.internal.call_recorder.time.time", return_value=T0 + RING_BUFFER_SECONDS + 10):
recorder._ingest(b"z")
assert min(timestamps(recorder)) <= (T0 + 1.0) - PRE_ROLL_SECONDS + CHUNK_INTERVAL
# ---------------------------------------------------------------------------
# Pre-roll and slicing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slice_starts_pre_roll_before_the_op25_timestamp(recorder):
fill(recorder, T0, T0 + 10.0)
grant_time = T0 + 5.0
await recorder.start_recording("call-1", start_epoch=grant_time)
assert recorder._slice_start == pytest.approx(grant_time - PRE_ROLL_SECONDS)
path = await recorder.stop_recording(end_epoch=grant_time + 2.0)
assert path is not None and path.exists()
# Reconstruct which chunks landed in the file.
kept = path.read_bytes().decode().strip(";").split(";")
first_index = int(kept[0][1:])
first_ts = T0 + first_index * CHUNK_INTERVAL
# A chunk stamped `ts` holds the audio that arrived over [ts - interval, ts],
# so the audio actually covered must begin at or before the requested slice
# start — erring early is the safe direction, erring late loses speech.
assert first_ts - CHUNK_INTERVAL <= grant_time - PRE_ROLL_SECONDS + 1e-6
# ...and no more than one chunk of extra pre-roll is dragged in.
assert first_ts >= grant_time - PRE_ROLL_SECONDS - 1e-6
@pytest.mark.asyncio
async def test_tail_chunk_straddling_the_end_is_included(recorder):
fill(recorder, T0, T0 + 10.0)
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
# End halfway through a chunk interval.
path = await recorder.stop_recording(end_epoch=T0 + 3.05)
kept = path.read_bytes().decode().strip(";").split(";")
last_ts = T0 + int(kept[-1][1:]) * CHUNK_INTERVAL
assert last_ts >= T0 + 3.05, "the chunk covering the end instant must be kept"
@pytest.mark.asyncio
async def test_pre_roll_earlier_than_buffer_start_is_clamped(recorder, caplog):
"""A grant older than anything buffered must still produce a file."""
fill(recorder, T0 + 5.0, T0 + 10.0) # buffer only covers T0+5 onwards
await recorder.start_recording("call-1", start_epoch=T0) # 5 s before the head
path = await recorder.stop_recording(end_epoch=T0 + 8.0)
assert path is not None and path.stat().st_size > 0
kept = path.read_bytes().decode().strip(";").split(";")
assert kept[0] == "A0", "slice should begin at the buffer head, not fail"
@pytest.mark.asyncio
async def test_no_buffered_audio_returns_none(recorder):
await recorder.start_recording("call-1", start_epoch=T0)
assert await recorder.stop_recording(end_epoch=T0 + 2.0) is None
@pytest.mark.asyncio
async def test_start_epoch_omitted_falls_back_to_now(recorder):
now = time.time()
fill(recorder, now - 5.0, now)
await recorder.start_recording("call-1")
assert recorder._slice_start == pytest.approx(now - PRE_ROLL_SECONDS, abs=1.0)
@pytest.mark.asyncio
async def test_max_recording_seconds_caps_the_slice(recorder):
fill(recorder, T0, T0 + MAX_RECORDING_SECONDS + 60, marker=b"A")
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
path = await recorder.stop_recording(end_epoch=T0 + MAX_RECORDING_SECONDS + 50)
kept = path.read_bytes().decode().strip(";").split(";")
last_ts = T0 + int(kept[-1][1:]) * CHUNK_INTERVAL
assert last_ts <= T0 + 1.0 + MAX_RECORDING_SECONDS + CHUNK_INTERVAL
# ---------------------------------------------------------------------------
# Recording lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_second_start_is_rejected_while_recording(recorder):
fill(recorder, T0, T0 + 5.0)
assert await recorder.start_recording("call-1", start_epoch=T0 + 1.0) is True
assert await recorder.start_recording("call-2", start_epoch=T0 + 2.0) is False
assert recorder.is_recording
@pytest.mark.asyncio
async def test_stop_without_start_is_a_noop(recorder):
assert await recorder.stop_recording() is None
assert not recorder.is_recording
@pytest.mark.asyncio
async def test_split_then_immediate_restart_keeps_both_slices(recorder):
"""
A tgid change closes one recording and opens the next at the same instant —
the second must still find its pre-roll in the buffer.
"""
fill(recorder, T0, T0 + 10.0)
split = T0 + 5.0
await recorder.start_recording("call-1", start_epoch=T0 + 1.0)
first = await recorder.stop_recording(end_epoch=split)
await recorder.start_recording("call-2", start_epoch=split)
second = await recorder.stop_recording(end_epoch=T0 + 8.0)
assert first is not None and first.stat().st_size > 0
assert second is not None and second.stat().st_size > 0
assert first != second
# ---------------------------------------------------------------------------
# FFmpeg invocation
# ---------------------------------------------------------------------------
def test_ffmpeg_command_reads_pulse_and_flushes_packets(recorder):
cmd = recorder._ffmpeg_command()
joined = " ".join(cmd)
assert "-f pulse" in joined
assert "drb_sink.monitor" in joined, "must address the monitor explicitly, not 'default'"
# Without -flush_packets the mp3 muxer buffers 32 KB (~16 s at 16 kbps) before
# writing, which would destroy the ring buffer's timestamp resolution.
assert "-flush_packets" in cmd
assert cmd[-1] == "-" and cmd[-2] == "mp3", "must emit MP3 on stdout for /upload"