466 lines
16 KiB
Python
466 lines
16 KiB
Python
import io
|
|
import math
|
|
import subprocess
|
|
import threading
|
|
import unittest
|
|
|
|
from pi_dictate_service.recorder import (
|
|
AUDIO_LEVEL_WINDOW_BYTES,
|
|
PW_RECORD_COMMAND,
|
|
PwRecorder,
|
|
RecorderError,
|
|
)
|
|
|
|
|
|
class TrackingStream(io.BytesIO):
|
|
def __init__(
|
|
self,
|
|
value: bytes,
|
|
events: list[str],
|
|
name: str,
|
|
max_read_size: int | None = None,
|
|
) -> None:
|
|
super().__init__(value)
|
|
self._events = events
|
|
self._name = name
|
|
self._max_read_size = max_read_size
|
|
self.eof = threading.Event()
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
if self._max_read_size is not None:
|
|
size = min(size, self._max_read_size)
|
|
value = super().read(size)
|
|
if not value and not self.eof.is_set():
|
|
self._events.append(f"{self._name}-eof")
|
|
self.eof.set()
|
|
return value
|
|
|
|
|
|
class BlockingTrackingStream(TrackingStream):
|
|
def __init__(self, value: bytes, events: list[str], name: str) -> None:
|
|
super().__init__(value, events, name)
|
|
self.allow_eof = threading.Event()
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
value = super().read(size)
|
|
if not value:
|
|
self.allow_eof.wait(1)
|
|
return value
|
|
|
|
|
|
class FakeProcess:
|
|
def __init__(
|
|
self,
|
|
stdout: TrackingStream,
|
|
stderr: TrackingStream,
|
|
*,
|
|
returncode: int | None = None,
|
|
timeout: bool = False,
|
|
terminate_returncode: int = -15,
|
|
kill_stalls: bool = False,
|
|
) -> None:
|
|
self.pid = 1234
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.returncode = returncode
|
|
self._timeout = timeout
|
|
self._terminate_returncode = terminate_returncode
|
|
self._kill_stalls = kill_stalls
|
|
self.events = stdout._events
|
|
|
|
def poll(self) -> int | None:
|
|
return self.returncode
|
|
|
|
def terminate(self) -> None:
|
|
self.events.append("terminate")
|
|
if not self._timeout:
|
|
self.returncode = self._terminate_returncode
|
|
|
|
def kill(self) -> None:
|
|
self.events.append("kill")
|
|
if not self._kill_stalls:
|
|
self.returncode = -9
|
|
self._timeout = False
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
self.events.append("wait")
|
|
if self._timeout:
|
|
raise subprocess.TimeoutExpired("pw-record", timeout)
|
|
if self.returncode is None:
|
|
self.returncode = 0
|
|
return self.returncode
|
|
|
|
|
|
class ProcessFactory:
|
|
def __init__(self, process: FakeProcess) -> None:
|
|
self.process = process
|
|
self.calls: list[tuple[list[str], dict[str, object]]] = []
|
|
|
|
def __call__(self, command: list[str], **kwargs: object) -> FakeProcess:
|
|
self.calls.append((command, kwargs))
|
|
return self.process
|
|
|
|
|
|
class StreamingSink:
|
|
def __init__(self) -> None:
|
|
self.chunks: list[bytes] = []
|
|
self.finalized = False
|
|
self.cancelled = False
|
|
|
|
def enqueue_pcm(self, chunk: bytes) -> None:
|
|
self.chunks.append(chunk)
|
|
|
|
def close_input(self) -> None:
|
|
self.finalized = True
|
|
|
|
def cancel(self) -> None:
|
|
self.cancelled = True
|
|
|
|
|
|
class Clock:
|
|
def __init__(self) -> None:
|
|
self.now = 0.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
class PwRecorderTests(unittest.TestCase):
|
|
def test_spawn_failure_is_already_cleanup_complete(self) -> None:
|
|
def fail_spawn(*_args: object, **_kwargs: object) -> FakeProcess:
|
|
raise OSError("spawn failed")
|
|
|
|
recorder = PwRecorder(process_factory=fail_spawn)
|
|
|
|
with self.assertRaisesRegex(RecorderError, "could not start"):
|
|
recorder.start()
|
|
self.assertTrue(recorder.cleanup_complete)
|
|
|
|
def make_recorder(
|
|
self,
|
|
pcm: bytes = b"\x00\x00\x01\x00",
|
|
stderr: bytes = b"",
|
|
*,
|
|
returncode: int | None = None,
|
|
timeout: bool = False,
|
|
max_bytes: int = 1024,
|
|
stderr_limit: int = 64 * 1024,
|
|
clock: Clock | None = None,
|
|
terminate_returncode: int = -15,
|
|
stdout_read_size: int | None = None,
|
|
kill_stalls: bool = False,
|
|
block_stdout_at_eof: bool = False,
|
|
pcm_sink: StreamingSink | None = None,
|
|
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
|
|
events: list[str] = []
|
|
stdout = (
|
|
BlockingTrackingStream(pcm, events, "stdout")
|
|
if block_stdout_at_eof
|
|
else TrackingStream(pcm, events, "stdout", stdout_read_size)
|
|
)
|
|
process = FakeProcess(
|
|
stdout,
|
|
TrackingStream(stderr, events, "stderr"),
|
|
returncode=returncode,
|
|
timeout=timeout,
|
|
terminate_returncode=terminate_returncode,
|
|
kill_stalls=kill_stalls,
|
|
)
|
|
factory = ProcessFactory(process)
|
|
def signal_group(pid: int, signal_number: int) -> None:
|
|
self.assertEqual(pid, process.pid)
|
|
if signal_number == 15:
|
|
process.terminate()
|
|
elif signal_number == 9:
|
|
process.kill()
|
|
else:
|
|
self.fail(f"unexpected signal: {signal_number}")
|
|
|
|
recorder = PwRecorder(
|
|
process_factory=factory,
|
|
max_bytes=max_bytes,
|
|
stderr_limit=stderr_limit,
|
|
stop_timeout=0.01,
|
|
clock=clock or Clock(),
|
|
process_group_signal=signal_group,
|
|
pcm_sink=pcm_sink,
|
|
)
|
|
return recorder, process, factory, events
|
|
|
|
def test_successful_stop_finalizes_streaming_sink(self) -> None:
|
|
sink = StreamingSink()
|
|
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
recorder.stop()
|
|
|
|
self.assertTrue(sink.finalized)
|
|
self.assertFalse(sink.cancelled)
|
|
|
|
def test_cancel_aborts_streaming_sink_without_finalizing(self) -> None:
|
|
sink = StreamingSink()
|
|
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
recorder.cancel()
|
|
|
|
self.assertTrue(sink.cancelled)
|
|
self.assertFalse(sink.finalized)
|
|
|
|
def test_unexpected_exit_aborts_streaming_sink_without_finalizing(self) -> None:
|
|
sink = StreamingSink()
|
|
recorder, process, _factory, _events = self.make_recorder(returncode=3, pcm_sink=sink)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
error = recorder.poll_unexpected()
|
|
|
|
self.assertIsNotNone(error)
|
|
self.assertTrue(sink.cancelled)
|
|
self.assertFalse(sink.finalized)
|
|
|
|
def test_spawns_exact_pw_record_command_and_drains_bounded_streams(self) -> None:
|
|
recorder, process, factory, events = self.make_recorder(
|
|
pcm=b"\x00\x00\x01\x00", stderr=b"0123456789", stderr_limit=8
|
|
)
|
|
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
capture = recorder.stop()
|
|
|
|
self.assertEqual(factory.calls[0][0], PW_RECORD_COMMAND)
|
|
self.assertIs(factory.calls[0][1]["stdout"], subprocess.PIPE)
|
|
self.assertIs(factory.calls[0][1]["stderr"], subprocess.PIPE)
|
|
self.assertIs(factory.calls[0][1]["start_new_session"], True)
|
|
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
|
|
self.assertEqual(capture.stderr_tail, b"23456789")
|
|
self.assertIn("stdout-eof", events)
|
|
self.assertIn("wait", events)
|
|
|
|
def test_tracks_normalized_audio_level_from_pcm_without_exposing_audio(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
pcm=b"\xff\x7f" * (AUDIO_LEVEL_WINDOW_BYTES // 2),
|
|
max_bytes=AUDIO_LEVEL_WINDOW_BYTES,
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
self.assertGreater(recorder.audio_level, 0.99)
|
|
self.assertLessEqual(recorder.audio_level, 1.0)
|
|
recorder.cancel()
|
|
|
|
def test_audio_level_accumulates_fragmented_reads_into_100ms_windows(self) -> None:
|
|
loud_sample = (32767).to_bytes(2, "little", signed=True)
|
|
pcm = (
|
|
loud_sample * (AUDIO_LEVEL_WINDOW_BYTES // 4)
|
|
+ b"\x00" * (AUDIO_LEVEL_WINDOW_BYTES // 2)
|
|
+ loud_sample * 400
|
|
)
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
pcm=pcm,
|
|
max_bytes=len(pcm),
|
|
stdout_read_size=800,
|
|
)
|
|
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
expected_rms = (0.5**0.5) * 32767 / 32768
|
|
expected_level = (20 * math.log10(expected_rms) + 60) / 60
|
|
self.assertAlmostEqual(recorder.audio_level, expected_level, places=3)
|
|
recorder.cancel()
|
|
|
|
def test_audio_level_maps_silence_known_rms_decay_and_partial_samples(self) -> None:
|
|
recorder = PwRecorder()
|
|
|
|
recorder._update_audio_level(b"\x00\x00" * 16)
|
|
self.assertEqual(recorder.audio_level, 0.0)
|
|
|
|
amplitude = 3277 # approximately -20 dBFS
|
|
recorder._update_audio_level(amplitude.to_bytes(2, "little", signed=True) * 16)
|
|
self.assertAlmostEqual(recorder.audio_level, 2 / 3, places=3)
|
|
|
|
recorder._update_audio_level(b"\x00\x00" * 16)
|
|
self.assertAlmostEqual(recorder.audio_level, (2 / 3) * 0.65, places=3)
|
|
|
|
previous = recorder.audio_level
|
|
recorder._update_audio_level(b"\xff")
|
|
self.assertEqual(recorder.audio_level, previous)
|
|
|
|
def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1)
|
|
recorder.start()
|
|
|
|
capture = recorder.stop()
|
|
|
|
self.assertEqual(capture.returncode, 1)
|
|
self.assertIsNone(capture.signal)
|
|
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
|
|
self.assertEqual(process.returncode, 1)
|
|
|
|
def test_rejects_code_one_if_process_already_exited_or_has_stderr(self) -> None:
|
|
exited, exited_process, _factory, _events = self.make_recorder(returncode=1)
|
|
exited.start()
|
|
self.assertTrue(exited_process.stdout.eof.wait(1))
|
|
with self.assertRaises(RecorderError):
|
|
exited.stop()
|
|
|
|
noisy, _process, _factory, _events = self.make_recorder(
|
|
stderr=b"pipewire failed",
|
|
terminate_returncode=1,
|
|
)
|
|
noisy.start()
|
|
with self.assertRaises(RecorderError):
|
|
noisy.stop()
|
|
|
|
def test_shutdown_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
timeout=True,
|
|
kill_stalls=True,
|
|
)
|
|
recorder.start()
|
|
|
|
with self.assertRaisesRegex(RecorderError, "after SIGKILL") as error:
|
|
recorder.stop()
|
|
|
|
self.assertEqual(error.exception.code, "recorder_timeout")
|
|
self.assertIn("kill", process.events)
|
|
|
|
def test_shutdown_retries_process_and_drainer_cleanup_after_double_timeout(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
timeout=True,
|
|
kill_stalls=True,
|
|
block_stdout_at_eof=True,
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
with self.assertRaises(RecorderError):
|
|
recorder.stop()
|
|
|
|
self.assertFalse(recorder.cleanup_complete)
|
|
process._kill_stalls = False
|
|
process.stdout.allow_eof.set()
|
|
|
|
recorder.cancel()
|
|
|
|
self.assertTrue(recorder.cleanup_complete)
|
|
|
|
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
|
|
recorder, _process, _factory, events = self.make_recorder(timeout=True)
|
|
recorder.start()
|
|
|
|
with self.assertRaises(RecorderError) as failed:
|
|
recorder.stop()
|
|
|
|
self.assertEqual(failed.exception.code, "recorder_timeout")
|
|
self.assertLess(events.index("terminate"), events.index("kill"))
|
|
|
|
def test_stop_rejects_an_exit_observed_after_the_last_listening_poll(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder()
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
# The process was alive during the listening poll, then exited cleanly
|
|
# before stop could signal it. A partial buffer is not a valid capture.
|
|
self.assertIsNone(recorder.poll_unexpected())
|
|
process.returncode = 0
|
|
|
|
with self.assertRaises(RecorderError) as failed:
|
|
recorder.stop()
|
|
|
|
self.assertEqual(failed.exception.code, "recorder_exit")
|
|
self.assertEqual(recorder.buffered_bytes, 0)
|
|
|
|
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
stderr=b"pipewire failed", returncode=7
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
failure = recorder.poll_unexpected()
|
|
|
|
self.assertIsNotNone(failure)
|
|
assert failure is not None
|
|
self.assertEqual(failure.code, "recorder_exit")
|
|
self.assertIn("code 7", failure.detail)
|
|
self.assertIn("pipewire failed", failure.detail)
|
|
|
|
signalled, signal_process, _factory, _events = self.make_recorder(returncode=-11)
|
|
signalled.start()
|
|
self.assertTrue(signal_process.stdout.eof.wait(1))
|
|
signal_failure = signalled.poll_unexpected()
|
|
assert signal_failure is not None
|
|
self.assertIn("signal 11", signal_failure.detail)
|
|
|
|
def test_duration_limit_discards_audio_and_reports_failure(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
pcm=b"\x00\x00" * 10, max_bytes=8
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
failure = recorder.poll_unexpected()
|
|
|
|
assert failure is not None
|
|
self.assertEqual(failure.code, "duration_limit")
|
|
self.assertEqual(recorder.buffered_bytes, 0)
|
|
|
|
def test_stop_reports_a_duration_limit_that_was_already_reached(self) -> None:
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
pcm=b"\x00\x00" * 10, max_bytes=8
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
|
|
with self.assertRaises(RecorderError) as failed:
|
|
recorder.stop()
|
|
|
|
self.assertEqual(failed.exception.code, "duration_limit")
|
|
self.assertEqual(recorder.buffered_bytes, 0)
|
|
|
|
def test_duration_limit_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
|
clock = Clock()
|
|
recorder, process, _factory, _events = self.make_recorder(
|
|
pcm=b"\x00\x00" * 3,
|
|
max_bytes=4,
|
|
timeout=True,
|
|
kill_stalls=True,
|
|
clock=clock,
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
clock.now = 1
|
|
|
|
failure = recorder.poll_unexpected()
|
|
|
|
self.assertIsNotNone(failure)
|
|
self.assertEqual(failure.code, "recorder_timeout")
|
|
self.assertIn("kill", process.events)
|
|
|
|
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
|
|
clock = Clock()
|
|
recorder, process, _factory, events = self.make_recorder(
|
|
pcm=b"\x00\x00" * 10,
|
|
max_bytes=8,
|
|
timeout=True,
|
|
clock=clock,
|
|
)
|
|
recorder.start()
|
|
self.assertTrue(process.stdout.eof.wait(1))
|
|
self.assertIsNone(recorder.poll_unexpected())
|
|
|
|
clock.now = 0.02
|
|
failure = recorder.poll_unexpected()
|
|
|
|
assert failure is not None
|
|
self.assertEqual(failure.code, "duration_limit")
|
|
self.assertIn("kill", events)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|