feat(dictate): support configurable ASR backends

This commit is contained in:
jb
2026-09-03 00:08:00 -04:00
parent 67877297d0
commit 2c3ee1f014
40 changed files with 7055 additions and 489 deletions
+156 -3
View File
@@ -36,6 +36,18 @@ class TrackingStream(io.BytesIO):
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,
@@ -45,6 +57,7 @@ class FakeProcess:
returncode: int | None = None,
timeout: bool = False,
terminate_returncode: int = -15,
kill_stalls: bool = False,
) -> None:
self.pid = 1234
self.stdout = stdout
@@ -52,6 +65,7 @@ class FakeProcess:
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:
@@ -64,8 +78,9 @@ class FakeProcess:
def kill(self) -> None:
self.events.append("kill")
self.returncode = -9
self._timeout = False
if not self._kill_stalls:
self.returncode = -9
self._timeout = False
def wait(self, timeout: float | None = None) -> int:
self.events.append("wait")
@@ -86,6 +101,22 @@ class ProcessFactory:
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
@@ -95,6 +126,16 @@ class Clock:
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",
@@ -107,14 +148,23 @@ class PwRecorderTests(unittest.TestCase):
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(
TrackingStream(pcm, events, "stdout", stdout_read_size),
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:
@@ -133,9 +183,44 @@ class PwRecorderTests(unittest.TestCase):
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
@@ -230,6 +315,39 @@ class PwRecorderTests(unittest.TestCase):
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()
@@ -240,6 +358,22 @@ class PwRecorderTests(unittest.TestCase):
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
@@ -288,6 +422,25 @@ class PwRecorderTests(unittest.TestCase):
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(