isr.py carried a plugin architecture — AudioBackend ABC, a _backend_classes registry, _discover_backends, available_backends/get_backend, a preferred_backend config knob — for exactly one implementation. It was also the source of three real bugs: * find_device() returned None before reaching the ALSA PCM-name fallback whenever `arecord -l` enumerated nothing, so the documented `device = shared_mic` (dsnoop from asound.conf) case failed outright on any host with no hardware card. The fallback is the whole point: those PCMs never appear in `arecord -l`. * `device = monitor` could never resolve. ALSA has no generic monitor source and list_devices hardcoded is_monitor=False, so the keyword — advertised in config.example.ini as the "EASY" way to capture system audio — only ever produced "device not found". Removed; it is a PulseAudio idea that outlived the PulseAudio backend. * list_all_devices() deduplicated by lowercased name "in case a device appears in multiple backends". With one backend what it actually did was delete the second of two identical USB cards. Dedup dropped. find_device() now always returns a device, so _resolve_device's unreachable "Available devices:" error is gone; an unknown spec is passed to ALSA verbatim and logged. AudioDevice loses backend/ is_monitor plus channels/sample_rate — the latter were hardcoded 2/44100 and only ever printed by --list-devices, i.e. numbers with no bearing on what gets recorded. --list-devices now says outright that it lists hardware cards only, which is where the shared_mic confusion started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
750 lines
26 KiB
Python
750 lines
26 KiB
Python
"""
|
|
Tests for isr.py
|
|
|
|
Run with: pytest tests/
|
|
"""
|
|
|
|
import logging
|
|
import struct
|
|
import time
|
|
import wave
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_logger():
|
|
logger = logging.getLogger("test")
|
|
logger.setLevel(logging.CRITICAL) # suppress noise during tests
|
|
return logger
|
|
|
|
|
|
def fixed_clock(dt: datetime):
|
|
"""Return a zero-argument callable that always returns *dt*."""
|
|
return lambda: dt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Import the module under test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
import isr # noqa: E402 (must come after sys.path manipulation)
|
|
|
|
|
|
# ===========================================================================
|
|
# AudioDevice / AudioSystem
|
|
# ===========================================================================
|
|
|
|
class TestAudioSystemFindDevice:
|
|
"""Tests for AudioSystem.find_device()."""
|
|
|
|
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
|
|
system = isr.AudioSystem.__new__(isr.AudioSystem)
|
|
system.logger = make_logger()
|
|
system.available = True
|
|
system.list_devices = lambda: devices
|
|
return system
|
|
|
|
def _dev(self, id_, name, *, is_default=False):
|
|
return isr.AudioDevice(id=id_, name=name, is_default=is_default)
|
|
|
|
def test_default_returns_default_device(self):
|
|
devices = [
|
|
self._dev("0", "Microphone"),
|
|
self._dev("1", "Stereo Mix", is_default=True),
|
|
]
|
|
system = self._make_system(devices)
|
|
result = system.find_device("default")
|
|
assert result.id == "1"
|
|
|
|
def test_default_falls_back_to_first_when_none_marked(self):
|
|
devices = [self._dev("0", "Mic"), self._dev("1", "Line In")]
|
|
system = self._make_system(devices)
|
|
result = system.find_device("default")
|
|
assert result.id == "0"
|
|
|
|
def test_default_with_no_cards_uses_the_alsa_default_pcm(self):
|
|
system = self._make_system([])
|
|
assert system.find_device("default").id == "default"
|
|
|
|
def test_exact_id_match(self):
|
|
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
|
|
system = self._make_system(devices)
|
|
assert system.find_device("42").name == "Device 42"
|
|
|
|
def test_exact_name_match_case_insensitive(self):
|
|
devices = [self._dev("0", "Stereo Mix")]
|
|
system = self._make_system(devices)
|
|
assert system.find_device("stereo mix").id == "0"
|
|
|
|
def test_partial_name_match(self):
|
|
devices = [self._dev("0", "Realtek Stereo Mix")]
|
|
system = self._make_system(devices)
|
|
assert system.find_device("stereo").id == "0"
|
|
|
|
def test_exact_id_wins_over_partial_name(self):
|
|
# Two identical USB cards: both must stay reachable. An earlier
|
|
# dedup-by-name dropped the second one entirely.
|
|
devices = [self._dev("hw:1,0", "USB Audio"), self._dev("hw:2,0", "USB Audio")]
|
|
system = self._make_system(devices)
|
|
assert system.find_device("hw:2,0").id == "hw:2,0"
|
|
|
|
def test_unknown_spec_becomes_a_literal_alsa_pcm_name(self):
|
|
# This is how `device = shared_mic` (a dsnoop PCM from asound.conf)
|
|
# resolves — it is never listed by `arecord -l`.
|
|
devices = [self._dev("hw:0,0", "Mic")]
|
|
system = self._make_system(devices)
|
|
assert system.find_device("shared_mic").id == "shared_mic"
|
|
|
|
def test_pcm_fallback_works_when_no_cards_are_enumerable(self):
|
|
# Regression: a `if not devices: return None` guard used to fire before
|
|
# the fallback, so shared_mic failed outright on a host with no
|
|
# enumerable hardware card.
|
|
system = self._make_system([])
|
|
assert system.find_device("shared_mic").id == "shared_mic"
|
|
|
|
|
|
# ===========================================================================
|
|
# BaseRecorder helpers
|
|
# ===========================================================================
|
|
|
|
class TestGetNextSplitTime:
|
|
"""Tests for BaseRecorder.get_next_split_time()."""
|
|
|
|
def _recorder(self, split_minutes: int, now: datetime) -> isr.BaseRecorder:
|
|
"""Create a concrete BaseRecorder subclass with an injected clock."""
|
|
class _Rec(isr.BaseRecorder):
|
|
def record(self): pass
|
|
|
|
cfg = {"split_minutes": split_minutes, "output_directory": "/tmp/isr_test"}
|
|
r = _Rec.__new__(_Rec)
|
|
r.name = "test"
|
|
r.config = cfg
|
|
r.logger = make_logger()
|
|
r.running = False
|
|
r.current_file = None
|
|
r.current_filename = None
|
|
r._clock = fixed_clock(now)
|
|
r.split_duration = split_minutes
|
|
r.output_dir = cfg["output_directory"]
|
|
r.max_retries = 3
|
|
r.retry_delay = 1
|
|
r.file_format = "auto"
|
|
return r
|
|
|
|
def test_30min_splits_at_next_half_hour(self):
|
|
now = datetime(2024, 1, 1, 14, 10, 30)
|
|
r = self._recorder(30, now)
|
|
nxt = r.get_next_split_time()
|
|
assert nxt == datetime(2024, 1, 1, 14, 30, 0)
|
|
|
|
def test_60min_splits_at_next_hour(self):
|
|
now = datetime(2024, 1, 1, 14, 45, 0)
|
|
r = self._recorder(60, now)
|
|
nxt = r.get_next_split_time()
|
|
assert nxt == datetime(2024, 1, 1, 15, 0, 0)
|
|
|
|
def test_exactly_on_boundary_schedules_next_period(self):
|
|
# At exactly 14:00 with 60-min splits → next split is 15:00
|
|
now = datetime(2024, 1, 1, 14, 0, 0)
|
|
r = self._recorder(60, now)
|
|
nxt = r.get_next_split_time()
|
|
assert nxt == datetime(2024, 1, 1, 15, 0, 0)
|
|
|
|
def test_120min_split_crosses_hour_boundary(self):
|
|
# 15:30 with 120-min splits → boundaries at 00:00, 02:00, …, 16:00
|
|
now = datetime(2024, 1, 1, 15, 30, 0)
|
|
r = self._recorder(120, now)
|
|
nxt = r.get_next_split_time()
|
|
assert nxt == datetime(2024, 1, 1, 16, 0, 0)
|
|
|
|
def test_seconds_are_zeroed(self):
|
|
now = datetime(2024, 1, 1, 12, 5, 45)
|
|
r = self._recorder(30, now)
|
|
assert r.get_next_split_time().second == 0
|
|
assert r.get_next_split_time().microsecond == 0
|
|
|
|
|
|
class TestGenerateFilename:
|
|
"""Tests for BaseRecorder.generate_filename()."""
|
|
|
|
def _recorder(self, now: datetime, output_dir: str) -> isr.BaseRecorder:
|
|
class _Rec(isr.BaseRecorder):
|
|
def record(self): pass
|
|
|
|
r = _Rec.__new__(_Rec)
|
|
r.name = "test"
|
|
r.config = {}
|
|
r.logger = make_logger()
|
|
r.running = False
|
|
r.current_file = None
|
|
r.current_filename = None
|
|
r._clock = fixed_clock(now)
|
|
r.split_duration = 60
|
|
r.output_dir = output_dir
|
|
r.max_retries = 3
|
|
r.retry_delay = 1
|
|
r.file_format = "auto"
|
|
return r
|
|
|
|
def test_fixed_format(self, tmp_path):
|
|
# Filenames are always <YYYYMMDD>_<HHMMSS>.<ext> — the recording start.
|
|
now = datetime(2024, 12, 25, 14, 30, 0)
|
|
r = self._recorder(now, str(tmp_path))
|
|
name = r.generate_filename("mp3")
|
|
assert name.endswith("20241225_143000.mp3")
|
|
|
|
def test_output_dir_prefix(self, tmp_path):
|
|
now = datetime(2024, 1, 1, 0, 0, 0)
|
|
r = self._recorder(now, str(tmp_path))
|
|
name = r.generate_filename("wav")
|
|
assert name.startswith(str(tmp_path))
|
|
|
|
|
|
# ===========================================================================
|
|
# StreamRecorder — format detection
|
|
# ===========================================================================
|
|
|
|
class TestDetectFormat:
|
|
"""Tests for StreamRecorder.detect_format()."""
|
|
|
|
def _recorder(self) -> isr.StreamRecorder:
|
|
cfg = {
|
|
"url": "http://example.com/stream",
|
|
"output_directory": "/tmp",
|
|
"split_minutes": 60,
|
|
"max_retries": 1,
|
|
"retry_delay_seconds": 0,
|
|
"format": "auto",
|
|
}
|
|
with patch.object(isr, "REQUESTS_AVAILABLE", True):
|
|
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
|
|
r.name = "test"
|
|
r.config = cfg
|
|
r.logger = make_logger()
|
|
r.running = False
|
|
r.current_file = None
|
|
r.current_filename = None
|
|
r._clock = datetime.now
|
|
r.split_duration = 60
|
|
r.output_dir = "/tmp"
|
|
r.max_retries = 1
|
|
r.retry_delay = 0
|
|
r.file_format = "auto"
|
|
r.stream_url = "http://example.com/stream"
|
|
r.username = None
|
|
r.password = None
|
|
r.stream_headers = None
|
|
r.header_capture_complete = False
|
|
r.detected_format = None
|
|
return r
|
|
|
|
def _response(self, content_type: str) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.headers = {"Content-Type": content_type}
|
|
return resp
|
|
|
|
@pytest.mark.parametrize("ct,expected", [
|
|
("audio/mpeg", "mp3"),
|
|
("audio/mp3", "mp3"),
|
|
("audio/ogg", "ogg"),
|
|
("application/ogg", "ogg"),
|
|
("audio/aac", "aac"),
|
|
("audio/aacp", "aac"),
|
|
("audio/flac", "flac"),
|
|
("audio/opus", "opus"),
|
|
])
|
|
def test_known_content_types(self, ct, expected):
|
|
r = self._recorder()
|
|
assert r.detect_format(self._response(ct)) == expected
|
|
|
|
def test_unknown_content_type_defaults_to_mp3(self):
|
|
r = self._recorder()
|
|
assert r.detect_format(self._response("application/octet-stream")) == "mp3"
|
|
|
|
def test_needs_header_per_file_ogg(self):
|
|
r = self._recorder()
|
|
r.detected_format = "ogg"
|
|
assert r.needs_header_per_file() is True
|
|
|
|
def test_needs_header_per_file_mp3(self):
|
|
r = self._recorder()
|
|
r.detected_format = "mp3"
|
|
assert r.needs_header_per_file() is False
|
|
|
|
|
|
# ===========================================================================
|
|
# StreamRecorder — OGG page parsing
|
|
# ===========================================================================
|
|
|
|
def _build_ogg_page(granule_pos: int = 0, is_bos: bool = False,
|
|
payload: bytes = b"\x00" * 10) -> bytes:
|
|
"""Build a minimal valid OGG page for testing."""
|
|
header_type = 0x02 if is_bos else 0x00
|
|
# Single segment containing the entire payload
|
|
num_segments = 1
|
|
segment_table = bytes([len(payload)])
|
|
# Granule position (8 bytes little-endian)
|
|
granule_bytes = struct.pack("<Q", granule_pos)
|
|
# serial number, page sequence number, checksum (all zeroed for simplicity)
|
|
header = (
|
|
b"OggS"
|
|
+ bytes([0x00]) # version
|
|
+ bytes([header_type]) # header type
|
|
+ granule_bytes # granule position
|
|
+ b"\x00" * 4 # serial number
|
|
+ b"\x00" * 4 # page sequence number
|
|
+ b"\x00" * 4 # checksum
|
|
+ bytes([num_segments]) # number of segments
|
|
+ segment_table # lacing values
|
|
+ payload
|
|
)
|
|
return header
|
|
|
|
|
|
class TestParseOggPage:
|
|
def _recorder(self) -> isr.StreamRecorder:
|
|
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
|
|
r.name = "test"
|
|
r.logger = make_logger()
|
|
return r
|
|
|
|
def test_parses_valid_page(self):
|
|
page = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"X" * 10)
|
|
r = self._recorder()
|
|
result = r.parse_ogg_page(page, 0)
|
|
assert result is not None
|
|
page_bytes, is_header, next_offset = result
|
|
assert page_bytes == page
|
|
assert next_offset == len(page)
|
|
|
|
def test_returns_none_for_non_ogg_data(self):
|
|
r = self._recorder()
|
|
assert r.parse_ogg_page(b"ID3\x00" + b"\x00" * 50, 0) is None
|
|
|
|
def test_returns_none_when_data_too_short(self):
|
|
r = self._recorder()
|
|
assert r.parse_ogg_page(b"OggS\x00\x02", 0) is None
|
|
|
|
def test_bos_flag_detected(self):
|
|
page = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"\x01" * 5)
|
|
r = self._recorder()
|
|
_, is_header, _ = r.parse_ogg_page(page, 0)
|
|
assert is_header is True
|
|
|
|
def test_non_zero_granule_is_not_header(self):
|
|
page = _build_ogg_page(granule_pos=1000, is_bos=False, payload=b"\x00" * 5)
|
|
r = self._recorder()
|
|
_, is_header, _ = r.parse_ogg_page(page, 0)
|
|
assert is_header is False
|
|
|
|
|
|
class TestExtractOggHeaders:
|
|
def _recorder(self) -> isr.StreamRecorder:
|
|
r = isr.StreamRecorder.__new__(isr.StreamRecorder)
|
|
r.name = "test"
|
|
r.logger = make_logger()
|
|
return r
|
|
|
|
def test_extracts_bos_pages(self):
|
|
header1 = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"H1" * 5)
|
|
header2 = _build_ogg_page(granule_pos=0, is_bos=False, payload=b"H2" * 5)
|
|
audio = _build_ogg_page(granule_pos=500, is_bos=False, payload=b"audio" * 3)
|
|
data = header1 + header2 + audio
|
|
r = self._recorder()
|
|
headers, remaining = r.extract_ogg_headers(data)
|
|
# Both header pages (granule_pos == 0) should be captured
|
|
assert header1 in headers
|
|
assert header2 in headers
|
|
|
|
def test_non_ogg_data_returns_empty_headers(self):
|
|
r = self._recorder()
|
|
headers, remaining = r.extract_ogg_headers(b"ID3\x00garbage")
|
|
assert headers == b""
|
|
|
|
def test_remaining_data_preserved(self):
|
|
header = _build_ogg_page(granule_pos=0, is_bos=True, payload=b"hdr")
|
|
audio = _build_ogg_page(granule_pos=100, payload=b"aud")
|
|
r = self._recorder()
|
|
headers, remaining = r.extract_ogg_headers(header + audio)
|
|
assert len(headers) > 0
|
|
# The audio page should be in remaining
|
|
assert audio in remaining or len(remaining) >= len(audio) - 5
|
|
|
|
|
|
# ===========================================================================
|
|
# _AudioFileWriter
|
|
# ===========================================================================
|
|
|
|
class TestAudioFileWriter:
|
|
def test_wav_writes_valid_file(self, tmp_path):
|
|
path = str(tmp_path / "out.wav")
|
|
writer = isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="wav")
|
|
# 1 frame of stereo int16 silence
|
|
writer.write(b"\x00" * 4)
|
|
writer.close()
|
|
|
|
with wave.open(path, "rb") as wf:
|
|
assert wf.getnchannels() == 2
|
|
assert wf.getsampwidth() == 2
|
|
assert wf.getframerate() == 44100
|
|
|
|
def test_flac_raises_when_soundfile_unavailable(self, tmp_path):
|
|
path = str(tmp_path / "out.flac")
|
|
with patch.object(isr, "SOUNDFILE_AVAILABLE", False):
|
|
with pytest.raises(ImportError, match="soundfile"):
|
|
isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="flac")
|
|
|
|
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
|
|
def test_flac_writes_valid_file(self, tmp_path):
|
|
import soundfile as sf
|
|
path = str(tmp_path / "out.flac")
|
|
writer = isr._AudioFileWriter(path, channels=2, sample_rate=44100, fmt="flac")
|
|
# 100ms of stereo silence at 44100 Hz → 44100 * 0.1 * 2 channels * 2 bytes = 17640 bytes
|
|
writer.write(b"\x00" * 17640)
|
|
writer.close()
|
|
info = sf.info(path)
|
|
assert info.channels == 2
|
|
assert info.samplerate == 44100
|
|
|
|
|
|
# ===========================================================================
|
|
# ALSAStream — capture liveness
|
|
# ===========================================================================
|
|
|
|
class TestALSAStreamLiveness:
|
|
"""check_alive() is what stops a dead arecord from being silently ignored."""
|
|
|
|
def _stream(self, poll_result=None) -> isr.ALSAStream:
|
|
device = isr.AudioDevice(
|
|
id="hw:0,0", name="Test",
|
|
)
|
|
s = isr.ALSAStream(device, 44100, 2, lambda data: None, make_logger())
|
|
s._process = MagicMock()
|
|
s._process.poll.return_value = poll_result
|
|
s._last_data = time.monotonic()
|
|
return s
|
|
|
|
def test_healthy_stream_does_not_raise(self):
|
|
self._stream().check_alive()
|
|
|
|
def test_raises_when_arecord_exited(self):
|
|
s = self._stream(poll_result=1)
|
|
with pytest.raises(RuntimeError, match="exited with code 1"):
|
|
s.check_alive()
|
|
|
|
def test_raises_when_no_audio_within_timeout(self):
|
|
# Process still up, but nothing has arrived — device yanked, or the
|
|
# dsnoop shared memory went away underneath us.
|
|
s = self._stream()
|
|
s._last_data = time.monotonic() - (isr.ALSAStream.SILENCE_TIMEOUT + 1)
|
|
with pytest.raises(RuntimeError, match="no audio"):
|
|
s.check_alive()
|
|
|
|
def test_read_loop_records_arrival_of_audio(self):
|
|
s = self._stream()
|
|
s._running = True
|
|
s._process.stdout.read.side_effect = [b"\x00" * 64, b""] # one chunk, then EOF
|
|
chunks = []
|
|
s.callback = chunks.append
|
|
|
|
s._read_loop()
|
|
|
|
assert chunks == [b"\x00" * 64]
|
|
assert s.received_audio is True
|
|
|
|
def test_read_loop_stops_at_eof_without_spinning(self):
|
|
s = self._stream()
|
|
s._running = True
|
|
s._process.stdout.read.return_value = b""
|
|
|
|
s._read_loop()
|
|
|
|
assert s._process.stdout.read.call_count == 1
|
|
assert s.received_audio is False
|
|
|
|
|
|
class _DeadStream:
|
|
"""Stand-in for a capture stream that opens but never delivers audio."""
|
|
|
|
received_audio = False
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
def check_alive(self):
|
|
raise RuntimeError("arecord exited with code 1")
|
|
|
|
|
|
# ===========================================================================
|
|
# RecorderManager — config loading
|
|
# ===========================================================================
|
|
|
|
class TestRecorderManagerLoadConfig:
|
|
"""Verify that config.ini settings are correctly parsed and applied."""
|
|
|
|
def _minimal_config(self, tmp_path) -> str:
|
|
log_file = str(tmp_path / "test.log")
|
|
return f"""
|
|
[general]
|
|
output_directory = {str(tmp_path / "recordings")}
|
|
split_minutes = 30
|
|
max_retries = 3
|
|
retry_delay_seconds = 2
|
|
log_level = WARNING
|
|
log_file = {log_file}
|
|
|
|
[my_stream]
|
|
type = stream
|
|
url = http://stream.example.com:8000/live
|
|
format = ogg
|
|
"""
|
|
|
|
def test_stream_recorder_created(self, tmp_path):
|
|
cfg_path = tmp_path / "config.ini"
|
|
cfg_path.write_text(self._minimal_config(tmp_path))
|
|
|
|
# Patch AudioSystem so it doesn't probe real hardware
|
|
with patch.object(isr, "AudioSystem") as mock_as:
|
|
mock_as.return_value.available = False
|
|
mgr = isr.RecorderManager(str(cfg_path))
|
|
|
|
assert len(mgr.recorders) == 1
|
|
rec = mgr.recorders[0]
|
|
assert isinstance(rec, isr.StreamRecorder)
|
|
assert rec.name == "my_stream"
|
|
assert rec.stream_url == "http://stream.example.com:8000/live"
|
|
|
|
def test_general_settings_inherited_by_source(self, tmp_path):
|
|
cfg_path = tmp_path / "config.ini"
|
|
cfg_path.write_text(self._minimal_config(tmp_path))
|
|
|
|
with patch.object(isr, "AudioSystem") as mock_as:
|
|
mock_as.return_value.available = False
|
|
mgr = isr.RecorderManager(str(cfg_path))
|
|
|
|
rec = mgr.recorders[0]
|
|
assert rec.split_duration == 30
|
|
assert rec.max_retries == 3
|
|
|
|
def test_source_overrides_general(self, tmp_path):
|
|
log_file = str(tmp_path / "test.log")
|
|
config_text = f"""
|
|
[general]
|
|
output_directory = {str(tmp_path / "recordings")}
|
|
split_minutes = 60
|
|
max_retries = 10
|
|
retry_delay_seconds = 5
|
|
log_level = WARNING
|
|
log_file = {log_file}
|
|
|
|
[overriding_stream]
|
|
type = stream
|
|
url = http://example.com/stream
|
|
split_minutes = 15
|
|
"""
|
|
cfg_path = tmp_path / "config.ini"
|
|
cfg_path.write_text(config_text)
|
|
|
|
with patch.object(isr, "AudioSystem") as mock_as:
|
|
mock_as.return_value.available = False
|
|
mgr = isr.RecorderManager(str(cfg_path))
|
|
|
|
rec = mgr.recorders[0]
|
|
assert rec.split_duration == 15
|
|
|
|
def test_unknown_type_is_skipped(self, tmp_path):
|
|
log_file = str(tmp_path / "test.log")
|
|
config_text = f"""
|
|
[general]
|
|
log_level = WARNING
|
|
log_file = {log_file}
|
|
|
|
[bad_source]
|
|
type = unknown_type
|
|
url = http://x.com/s
|
|
|
|
[good_source]
|
|
type = stream
|
|
url = http://x.com/s2
|
|
"""
|
|
cfg_path = tmp_path / "config.ini"
|
|
cfg_path.write_text(config_text)
|
|
|
|
with patch.object(isr, "AudioSystem") as mock_as:
|
|
mock_as.return_value.available = False
|
|
mgr = isr.RecorderManager(str(cfg_path))
|
|
|
|
assert len(mgr.recorders) == 1
|
|
assert mgr.recorders[0].name == "good_source"
|
|
|
|
def test_missing_config_file_exits(self, tmp_path):
|
|
with pytest.raises(SystemExit):
|
|
# main() calls sys.exit(1) when the file is missing
|
|
with patch("sys.argv", ["isr.py", str(tmp_path / "nonexistent.ini")]):
|
|
isr.main()
|
|
|
|
|
|
# ===========================================================================
|
|
# StreamRecorder — record() integration (mocked HTTP)
|
|
# ===========================================================================
|
|
|
|
class TestStreamRecorderRecord:
|
|
"""Integration-level tests with a mocked requests session."""
|
|
|
|
def _recorder(self, fmt="mp3", clock=None) -> isr.StreamRecorder:
|
|
cfg = {
|
|
"url": "http://example.com/stream",
|
|
"output_directory": "", # overridden per-test with tmp_path
|
|
"split_minutes": 60,
|
|
"max_retries": 2,
|
|
"retry_delay_seconds": 0,
|
|
"format": fmt,
|
|
}
|
|
with patch.object(isr, "REQUESTS_AVAILABLE", True):
|
|
r = isr.StreamRecorder(
|
|
"test_stream", cfg, make_logger(), clock=clock or datetime.now
|
|
)
|
|
return r
|
|
|
|
def test_connection_failure_retries(self, tmp_path):
|
|
rec = self._recorder()
|
|
rec.output_dir = str(tmp_path)
|
|
rec.max_retries = 2
|
|
|
|
with patch.object(rec, "connect_stream", return_value=None) as mock_connect:
|
|
# connect_stream always fails → recorder gives up after max_retries
|
|
rec.record()
|
|
|
|
assert mock_connect.call_count == rec.max_retries
|
|
|
|
|
|
# ===========================================================================
|
|
# SoundcardRecorder — unit tests
|
|
# ===========================================================================
|
|
|
|
class TestSoundcardRecorder:
|
|
def _make_device(self) -> isr.AudioDevice:
|
|
return isr.AudioDevice(id="0", name="Test Device")
|
|
|
|
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
|
|
system = MagicMock(spec=isr.AudioSystem)
|
|
system.available = True
|
|
system.find_device.return_value = device
|
|
return system
|
|
|
|
def test_wav_file_written_correctly(self, tmp_path):
|
|
device = self._make_device()
|
|
audio_system = self._make_audio_system(device)
|
|
|
|
cfg = {
|
|
"device": "default",
|
|
"sample_rate": 44100,
|
|
"channels": 2,
|
|
"format": "wav",
|
|
"output_directory": str(tmp_path),
|
|
"split_minutes": 60,
|
|
"max_retries": 1,
|
|
"retry_delay_seconds": 0,
|
|
}
|
|
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
|
|
rec._open_output_file()
|
|
|
|
# Simulate audio callback delivering one chunk
|
|
silence = b"\x00" * (44100 * 2 * 2 // 10) # 100ms of stereo int16 silence
|
|
rec._audio_callback(silence)
|
|
rec.close_current_file()
|
|
|
|
out_files = list(tmp_path.glob("*.wav"))
|
|
assert len(out_files) == 1
|
|
with wave.open(str(out_files[0]), "rb") as wf:
|
|
assert wf.getnchannels() == 2
|
|
assert wf.getframerate() == 44100
|
|
|
|
def test_flac_raises_when_soundfile_unavailable(self, tmp_path):
|
|
device = self._make_device()
|
|
audio_system = self._make_audio_system(device)
|
|
|
|
cfg = {
|
|
"device": "default",
|
|
"sample_rate": 44100,
|
|
"channels": 2,
|
|
"format": "flac",
|
|
"output_directory": str(tmp_path),
|
|
"split_minutes": 60,
|
|
"max_retries": 1,
|
|
"retry_delay_seconds": 0,
|
|
}
|
|
with patch.object(isr, "SOUNDFILE_AVAILABLE", False):
|
|
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
|
|
with pytest.raises(ImportError, match="soundfile"):
|
|
rec._open_output_file()
|
|
|
|
def test_dead_capture_gives_up_instead_of_recording_nothing(self, tmp_path):
|
|
# A capture that opens but never delivers audio must exhaust
|
|
# max_retries and stop. Before check_alive() existed, record() sat in
|
|
# its inner loop forever, rolling over to a new split file every
|
|
# split_minutes and writing zero bytes into each one.
|
|
device = self._make_device()
|
|
audio_system = self._make_audio_system(device)
|
|
audio_system.open_stream.return_value = _DeadStream()
|
|
|
|
cfg = {
|
|
"device": "default",
|
|
"sample_rate": 44100,
|
|
"channels": 2,
|
|
"format": "wav",
|
|
"output_directory": str(tmp_path),
|
|
"split_minutes": 60,
|
|
"max_retries": 2,
|
|
"retry_delay_seconds": 0,
|
|
}
|
|
rec = isr.SoundcardRecorder("sc_dead", cfg, make_logger(), audio_system=audio_system)
|
|
with patch.object(isr.time, "sleep"): # don't pay the 0.5 s poll interval
|
|
rec.record()
|
|
|
|
assert audio_system.open_stream.call_count == cfg["max_retries"]
|
|
|
|
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
|
|
def test_flac_file_written_correctly(self, tmp_path):
|
|
import soundfile as sf
|
|
|
|
device = self._make_device()
|
|
audio_system = self._make_audio_system(device)
|
|
|
|
cfg = {
|
|
"device": "default",
|
|
"sample_rate": 44100,
|
|
"channels": 2,
|
|
"format": "flac",
|
|
"output_directory": str(tmp_path),
|
|
"split_minutes": 60,
|
|
"max_retries": 1,
|
|
"retry_delay_seconds": 0,
|
|
}
|
|
rec = isr.SoundcardRecorder("sc_test", cfg, make_logger(), audio_system=audio_system)
|
|
rec._open_output_file()
|
|
|
|
silence = b"\x00" * (44100 * 2 * 2 // 10)
|
|
rec._audio_callback(silence)
|
|
rec.close_current_file()
|
|
|
|
out_files = list(tmp_path.glob("*.flac"))
|
|
assert len(out_files) == 1
|
|
info = sf.info(str(out_files[0]))
|
|
assert info.channels == 2
|
|
assert info.samplerate == 44100
|