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()