Move recording and Discord voice to PulseAudio
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user