diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1d93ec..a13a688a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project are documented here. The format is based on ### Fixed +- **Voice plugin (0.5.2):** added playback-aware muting when `--speak` and + `--voice` run together, zero-filling captured microphone blocks during active + TTS playback ([#332](https://github.com/robocurve/inspect-robots/issues/332)). + - **Voice plugin (0.5.1):** operator-ended trials now cut `--speak` narration instead of draining it at eval end ([plan 0061](plans/0061-speak-operator-end-cut.md), diff --git a/plugins/inspect-robots-voice/pyproject.toml b/plugins/inspect-robots-voice/pyproject.toml index 0bd04b12..b7f592c0 100644 --- a/plugins/inspect-robots-voice/pyproject.toml +++ b/plugins/inspect-robots-voice/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "inspect-robots-voice" -version = "0.5.1" +version = "0.5.2" description = "Local spoken operator feedback for attended Inspect Robots evaluations." dynamic = ["readme"] requires-python = ">=3.10" diff --git a/plugins/inspect-robots-voice/src/inspect_robots_voice/__init__.py b/plugins/inspect-robots-voice/src/inspect_robots_voice/__init__.py index 993d4f54..6c6fd8f7 100644 --- a/plugins/inspect-robots-voice/src/inspect_robots_voice/__init__.py +++ b/plugins/inspect-robots-voice/src/inspect_robots_voice/__init__.py @@ -14,7 +14,7 @@ __all__ = ["SpeakerSink", "VoiceInput", "speaker_sink", "voice_input"] -__version__ = "0.5.1" +__version__ = "0.5.2" ScalarValue = str | int | float | bool | None diff --git a/plugins/inspect-robots-voice/src/inspect_robots_voice/_capture.py b/plugins/inspect-robots-voice/src/inspect_robots_voice/_capture.py index 0f7954bc..c8c11ea3 100644 --- a/plugins/inspect-robots-voice/src/inspect_robots_voice/_capture.py +++ b/plugins/inspect-robots-voice/src/inspect_robots_voice/_capture.py @@ -3,6 +3,7 @@ from __future__ import annotations import queue as queue_module +import threading import warnings from collections.abc import Mapping, Sequence from contextlib import suppress @@ -15,6 +16,10 @@ Device = str | int | None +_speakers_lock = threading.Lock() +_active_speakers: set[object] = set() + + class MicrophoneCapture: """Capture 16-bit-equivalent mono float blocks without blocking PortAudio's callback.""" @@ -130,6 +135,10 @@ def _callback( ) -> None: del frames, time_info, status block = np.asarray(indata, dtype=np.float32).reshape(-1).copy() + with _speakers_lock: + active = bool(_active_speakers) + if active: + block.fill(0) try: self._queue.put_nowait(block) return diff --git a/plugins/inspect-robots-voice/src/inspect_robots_voice/_speaker.py b/plugins/inspect-robots-voice/src/inspect_robots_voice/_speaker.py index d8eecf42..f74bb020 100644 --- a/plugins/inspect-robots-voice/src/inspect_robots_voice/_speaker.py +++ b/plugins/inspect-robots-voice/src/inspect_robots_voice/_speaker.py @@ -309,12 +309,20 @@ def _worker(self, engine: TtsEngine, playback: _Playback) -> None: continue gained = np.asarray(samples * np.float32(self.volume), dtype=np.float32) chunk_size = max(1, int(sample_rate * _CHUNK_SECONDS)) - for start in range(0, len(gained), chunk_size): - if self._stop.is_set(): - return - if self._speech_gen != gen: - break - playback.write(gained[start : start + chunk_size], sample_rate) + from inspect_robots_voice._capture import _active_speakers, _speakers_lock + + with _speakers_lock: + _active_speakers.add(self) + try: + for start in range(0, len(gained), chunk_size): + if self._stop.is_set(): + return + if self._speech_gen != gen: + break + playback.write(gained[start : start + chunk_size], sample_rate) + finally: + with _speakers_lock: + _active_speakers.discard(self) except Exception as exc: with self._condition: self._disabled = True diff --git a/plugins/inspect-robots-voice/tests/test_capture.py b/plugins/inspect-robots-voice/tests/test_capture.py index 0ce8b3c2..e5226d1a 100644 --- a/plugins/inspect-robots-voice/tests/test_capture.py +++ b/plugins/inspect-robots-voice/tests/test_capture.py @@ -172,3 +172,29 @@ def failing_import(name: str, *args: object, **kwargs: object) -> object: with pytest.raises(OSError, match="libportaudio2"): MicrophoneCapture(None, 16_000, audio_queue) + + +def test_playback_aware_muting_callback() -> None: + from inspect_robots_voice._capture import _active_speakers, _speakers_lock + + sounddevice = _SoundDevice(_DEVICES) + audio_queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=2) + capture = MicrophoneCapture(1, 16_000, audio_queue, _sounddevice=sounddevice) + + # When no speakers are active, blocks should be preserved. + with _speakers_lock: + _active_speakers.clear() + + capture._callback(np.array([[5.0], [6.0]]), 2, object(), object()) + assert np.array_equal(audio_queue.get_nowait(), np.array([5.0, 6.0], dtype=np.float32)) + + # When a speaker is active, blocks should be zero-filled. + dummy_speaker = object() + with _speakers_lock: + _active_speakers.add(dummy_speaker) + try: + capture._callback(np.array([[5.0], [6.0]]), 2, object(), object()) + assert np.array_equal(audio_queue.get_nowait(), np.array([0.0, 0.0], dtype=np.float32)) + finally: + with _speakers_lock: + _active_speakers.discard(dummy_speaker) diff --git a/plugins/inspect-robots-voice/tests/test_factory.py b/plugins/inspect-robots-voice/tests/test_factory.py index 2733b467..8259e29c 100644 --- a/plugins/inspect-robots-voice/tests/test_factory.py +++ b/plugins/inspect-robots-voice/tests/test_factory.py @@ -11,7 +11,7 @@ def test_package_exports_and_version() -> None: - assert inspect_robots_voice.__version__ == "0.5.1" + assert inspect_robots_voice.__version__ == "0.5.2" assert inspect_robots_voice.__all__ == [ "SpeakerSink", "VoiceInput", diff --git a/plugins/inspect-robots-voice/tests/test_speaker.py b/plugins/inspect-robots-voice/tests/test_speaker.py index f1bb99b7..2273a107 100644 --- a/plugins/inspect-robots-voice/tests/test_speaker.py +++ b/plugins/inspect-robots-voice/tests/test_speaker.py @@ -748,3 +748,35 @@ def enqueue_degraded() -> None: playback.release[0].set() sink.close() + + +def test_speaker_active_playback_registration() -> None: + from inspect_robots_voice._capture import _active_speakers, _speakers_lock + + engine = _FakeEngine() + playback = _GatedPlayback(gated_writes=1) + sink = _sink(engine, playback) + sink.start() + + with _speakers_lock: + _active_speakers.clear() + + sink.log_policy_messages( + 0, [_assistant(_tool_call("move", {"note": "test-playback-registration"}))] + ) + + # Wait for the worker to synthesize and start writing the chunk + assert playback.entered[0].wait(timeout=2.0) + + # The speaker should be registered in _active_speakers during playback + with _speakers_lock: + assert sink in _active_speakers + + # Release the playback write chunk + playback.release[0].set() + _wait_until(lambda: len(playback.writes) == 3) + sink.close() + + # The speaker should be discarded from _active_speakers when done + with _speakers_lock: + assert sink not in _active_speakers diff --git a/plugins/inspect-robots-voice/tests/test_tts.py b/plugins/inspect-robots-voice/tests/test_tts.py index 2bf8d903..32faee10 100644 --- a/plugins/inspect-robots-voice/tests/test_tts.py +++ b/plugins/inspect-robots-voice/tests/test_tts.py @@ -42,7 +42,9 @@ def test_missing_explicit_path_names_the_path( missing = tmp_path / "missing" monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) - with pytest.raises(FileNotFoundError, match=str(missing)): + import re + + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): resolve_model_files( str(missing) if kind == "model" else str(present), str(missing) if kind == "voices" else str(present), diff --git a/uv.lock b/uv.lock index 2635058a..8d4366d2 100644 --- a/uv.lock +++ b/uv.lock @@ -315,7 +315,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly" }, + { name = "humanfriendly", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -430,15 +430,15 @@ name = "csvw" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "babel" }, - { name = "isodate" }, - { name = "jsonschema" }, - { name = "language-tags" }, - { name = "python-dateutil" }, - { name = "rdflib" }, - { name = "rfc3986" }, - { name = "termcolor" }, - { name = "uritemplate" }, + { name = "babel", marker = "python_full_version < '3.15'" }, + { name = "isodate", marker = "python_full_version < '3.15'" }, + { name = "jsonschema", marker = "python_full_version < '3.15'" }, + { name = "language-tags", marker = "python_full_version < '3.15'" }, + { name = "python-dateutil", marker = "python_full_version < '3.15'" }, + { name = "rdflib", marker = "python_full_version < '3.15'" }, + { name = "rfc3986", marker = "python_full_version < '3.15'" }, + { name = "termcolor", marker = "python_full_version < '3.15'" }, + { name = "uritemplate", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/f1/95a10e22505e84bf346dd8df41d9c6135aea564d83d8e39b79b0851ba4db/csvw-4.1.0.tar.gz", hash = "sha256:bfe2b64442552b392577a8d30edf886510c8d05e665606fa5caa1508365fd700", size = 83167, upload-time = "2026-07-06T08:37:26.378Z" } wheels = [ @@ -524,7 +524,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -696,7 +696,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -915,7 +915,7 @@ provides-extras = ["dev"] [[package]] name = "inspect-robots-voice" -version = "0.5.1" +version = "0.5.2" source = { editable = "plugins/inspect-robots-voice" } dependencies = [ { name = "faster-whisper" }, @@ -1008,11 +1008,11 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "jsonschema-specifications", marker = "python_full_version < '3.15'" }, + { name = "referencing", marker = "python_full_version < '3.15'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1024,7 +1024,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing" }, + { name = "referencing", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -1036,12 +1036,12 @@ name = "kokoro-onnx" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "espeakng-loader" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "onnxruntime", version = "1.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "phonemizer-fork" }, + { name = "espeakng-loader", marker = "python_full_version < '3.15'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "onnxruntime", version = "1.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "phonemizer-fork", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/18/277bd18aeeceaf5c46a51bb50c1cbdccdad8cab7fd1d58f0173bbeeec708/kokoro_onnx-0.5.0.tar.gz", hash = "sha256:5beb15f085e2828ed8d493f792c079af857103ab2dceaa1e112b1760587ac96a", size = 84570, upload-time = "2026-01-30T03:05:45.6Z" } wheels = [ @@ -1496,12 +1496,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, + { name = "coloredlogs", marker = "python_full_version < '3.11'" }, + { name = "flatbuffers", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "protobuf", marker = "python_full_version < '3.11'" }, + { name = "sympy", marker = "python_full_version < '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, @@ -1537,10 +1537,10 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.15'", ] dependencies = [ - { name = "flatbuffers" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "protobuf" }, + { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "protobuf", marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, @@ -1592,11 +1592,11 @@ name = "phonemizer-fork" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "dlinfo" }, - { name = "joblib" }, - { name = "segments" }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "dlinfo", marker = "python_full_version < '3.15'" }, + { name = "joblib", marker = "python_full_version < '3.15'" }, + { name = "segments", marker = "python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/fa/9294d2f11890ca49d0bdac7a4da60cbe5686629bfd4987cae0ad75e051cc/phonemizer_fork-3.3.2.tar.gz", hash = "sha256:10e16e827d0443b087062e21b55e805c00989cf1343b2e81e734cae5f6c0cf69", size = 300989, upload-time = "2025-01-30T13:02:31.201Z" } wheels = [ @@ -1904,7 +1904,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -1993,8 +1993,8 @@ name = "rdflib" version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "isodate", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pyparsing" }, + { name = "isodate", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } wheels = [ @@ -2006,10 +2006,10 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version != '3.13.*'" }, + { name = "attrs", marker = "python_full_version < '3.15'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.15'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -2447,8 +2447,8 @@ name = "segments" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "csvw" }, - { name = "regex" }, + { name = "csvw", marker = "python_full_version < '3.15'" }, + { name = "regex", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/57/85cac3a8e32370e88fa5fa92812edb6025db7fcbed51452bd56ee1524957/segments-2.4.0.tar.gz", hash = "sha256:bba71f5520ddd54c8aa2f4d765a60618c6862162d6e7356a4a097f2223166f5b", size = 18662, upload-time = "2026-03-07T10:01:28.925Z" } wheels = [ @@ -2494,7 +2494,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [