Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion plugins/inspect-robots-voice/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions plugins/inspect-robots-voice/src/inspect_robots_voice/_speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions plugins/inspect-robots-voice/tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion plugins/inspect-robots-voice/tests/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions plugins/inspect-robots-voice/tests/test_speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion plugins/inspect-robots-voice/tests/test_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
100 changes: 50 additions & 50 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading