Spaces:
Running
Running
| """Unit tests for capture, VAD gating, resampling and playback.""" | |
| import time | |
| import numpy as np | |
| from yrobot.audio import ( | |
| AUDIO_STARTUP_CONFIG, | |
| FRAME_SAMPLES, | |
| Microphone, | |
| PlaybackEchoMatcher, | |
| Speaker, | |
| StreamResampler, | |
| UplinkGain, | |
| VoiceDetector, | |
| apply_audio_startup_config, | |
| ) | |
| class FakeVad: | |
| def __init__(self, result=True): | |
| self.result = result | |
| def is_speech(self, pcm, rate): | |
| return self.result | |
| class FakeMedia: | |
| def __init__(self): | |
| self.pushed = [] | |
| self.cleared = 0 | |
| self.samples = [] | |
| self.audio = self | |
| def get_audio_sample(self): | |
| return self.samples.pop(0) if self.samples else None | |
| def push_audio_sample(self, data): | |
| self.pushed.append(np.asarray(data)) | |
| def clear_player(self): | |
| self.cleared += 1 | |
| def test_duplex_audio_profile_uses_verified_sdk_config(): | |
| class ConfigurableAudio: | |
| def __init__(self): | |
| self.calls = [] | |
| def apply_audio_config(self, config, *, verify, write_settle_seconds): | |
| self.calls.append((config, verify, write_settle_seconds)) | |
| return True | |
| media = FakeMedia() | |
| media.audio = ConfigurableAudio() | |
| assert apply_audio_startup_config(media, write_settle_seconds=0) is True | |
| assert media.audio.calls == [(AUDIO_STARTUP_CONFIG, True, 0)] | |
| def test_duplex_audio_profile_is_best_effort_without_sdk_api(): | |
| media = FakeMedia() | |
| media.audio = object() | |
| assert apply_audio_startup_config(media) is False | |
| def test_microphone_reframes_arbitrary_stereo_blocks(): | |
| media = FakeMedia() | |
| media.samples = [np.zeros((450, 2), np.float32), np.zeros((200, 2), np.float32)] | |
| mic = Microphone(media) | |
| assert [len(f) for f in mic.read_frames()] == [FRAME_SAMPLES] | |
| assert [len(f) for f in mic.read_frames()] == [FRAME_SAMPLES] # 130 carried over | |
| def test_voice_detector_needs_streak_and_energy(): | |
| det = VoiceDetector(vad=FakeVad(True)) | |
| loud = np.full(FRAME_SAMPLES, 0.1, np.float32) | |
| quiet = np.full(FRAME_SAMPLES, 1e-4, np.float32) | |
| assert det.process(quiet, 0.00) is False # energy below floor gate | |
| assert det.process(loud, 0.02) is False # streak 1 | |
| assert det.process(loud, 0.04) is False # streak 2 | |
| assert det.process(loud, 0.06) is True # confirmed at 3 | |
| assert det.active(0.30) is True | |
| assert det.active(0.40) is False | |
| def test_voice_detector_adapts_noise_floor(): | |
| det = VoiceDetector(vad=FakeVad(True)) | |
| hum = np.full(FRAME_SAMPLES, 0.02, np.float32) # steady motor noise | |
| for i in range(400): | |
| det.process(hum, i * 0.02) | |
| assert det.process(hum, 9.0) is False # floor swallowed the hum | |
| speech = np.full(FRAME_SAMPLES, 0.3, np.float32) | |
| for i in range(3): | |
| det.process(speech, 10.0 + i * 0.02) | |
| assert det.process(speech, 10.06) is True | |
| def test_voice_detector_frozen_floor_keeps_barge_sensitivity(): | |
| det = VoiceDetector(vad=FakeVad(True)) | |
| echo = np.full(FRAME_SAMPLES, 0.05, np.float32) | |
| # 10 s of the robot's own monologue echo: the floor must not learn it | |
| for i in range(500): | |
| det.process(echo, i * 0.02, floor_frozen=True) | |
| speech = np.full(FRAME_SAMPLES, 0.05, np.float32) # user at the same level | |
| voiced = False | |
| for i in range(3): | |
| voiced = det.process(speech, 11.0 + i * 0.02, floor_frozen=True) | |
| assert voiced is True # without the freeze the floor would gate this out | |
| def _speech_like_pcm(samples: int, seed: int) -> np.ndarray: | |
| rng = np.random.default_rng(seed) | |
| excitation = rng.normal(size=samples + 4) | |
| colored = np.convolve(excitation, [0.03, 0.12, 0.35, 0.12, 0.03], mode="valid") | |
| envelope = np.repeat( | |
| np.clip(rng.normal(0.6, 0.25, samples // 160 + 1), 0.05, 1.0), | |
| 160, | |
| )[:samples] | |
| return (colored * envelope * 0.2).astype(np.float32) | |
| def test_playback_echo_matcher_finds_delayed_filtered_echo(): | |
| matcher = PlaybackEchoMatcher() | |
| playout = _speech_like_pcm(32_000, seed=1) | |
| matcher.record(8.0, playout) | |
| source = playout[24_000:27_200] | |
| filtered = np.convolve(source, [0.2, 0.6, 0.2], mode="same") | |
| mic = (filtered * 0.12).astype(np.float32) | |
| match = matcher.match(mic, now=10.0) | |
| assert match.similarity > 0.9 | |
| assert match.unexplained_db < -42.0 | |
| assert 250.0 < match.lag_ms < 750.0 | |
| def test_playback_echo_matcher_leaves_unrelated_near_end_energy(): | |
| matcher = PlaybackEchoMatcher() | |
| matcher.record(8.0, _speech_like_pcm(32_000, seed=2)) | |
| user = _speech_like_pcm(3_200, seed=3) * 0.25 | |
| match = matcher.match(user, now=10.0) | |
| assert match.similarity < 0.5 | |
| assert match.unexplained_db > -42.0 | |
| def test_uplink_gain_boosts_quiet_speech_not_noise(): | |
| agc = UplinkGain() | |
| speech = np.full(8000, 0.03, np.float32) # −30 dB: typical XVF capture | |
| first = agc.process(speech) | |
| assert float(np.abs(first).max()) > 0.03 # boosting immediately | |
| frozen = agc.gain | |
| agc.process(np.full(8000, 0.001, np.float32)) # room noise: no update | |
| assert agc.gain == frozen | |
| for _ in range(20): | |
| out = agc.process(speech) | |
| assert abs(float(np.sqrt(np.mean(np.square(out)))) - UplinkGain.TARGET_RMS) < 0.02 | |
| def test_uplink_gain_never_amplifies_loud_speech_or_clips(): | |
| agc = UplinkGain() | |
| loud = np.full(8000, 0.5, np.float32) | |
| for _ in range(10): | |
| out = agc.process(loud) | |
| assert agc.gain == 1.0 | |
| assert float(np.abs(out).max()) <= 1.0 | |
| def test_uplink_gain_releases_in_playback_without_confirmed_user(): | |
| agc = UplinkGain() | |
| quiet_speech = np.full(8000, 0.02, np.float32) | |
| for _ in range(10): | |
| agc.process(quiet_speech) | |
| boosted = agc.gain | |
| assert boosted > 2.0 | |
| for _ in range(5): | |
| agc.process( | |
| quiet_speech, | |
| playback_active=True, | |
| confirmed_user_voice=False, | |
| ) | |
| assert 1.0 < agc.gain < boosted | |
| def test_resampler_ratio_and_continuity(): | |
| rs = StreamResampler(24_000, 16_000) | |
| ramp = np.linspace(0.0, 1.0, 24_000, dtype=np.float32) | |
| out = np.concatenate([rs.process(chunk) for chunk in np.array_split(ramp, 13)]) | |
| assert abs(len(out) - 16_000) <= 2 | |
| assert np.all(np.diff(out[32:]) >= -1e-6) # startup FIR ringing only; no seams | |
| def test_resampler_filters_content_above_output_nyquist(): | |
| t = np.arange(24_000) / 24_000 | |
| passband = np.sin(2 * np.pi * 1000 * t).astype(np.float32) | |
| stopband = np.sin(2 * np.pi * 10_000 * t).astype(np.float32) | |
| low = StreamResampler().process(passband)[100:] | |
| high = StreamResampler().process(stopband)[100:] | |
| assert float(np.sqrt(np.mean(np.square(high)))) < 0.1 * float(np.sqrt(np.mean(np.square(low)))) | |
| def test_speaker_plays_after_boundary_and_flushes_on_interrupt(): | |
| media = FakeMedia() | |
| speaker = Speaker(media) | |
| speaker.start() | |
| try: | |
| speaker.play(speaker.epoch, np.ones(2400, np.float32)) # 100 ms < preroll | |
| speaker.utterance_end() # boundary flushes the short reply out | |
| deadline = time.monotonic() + 2.0 | |
| while not media.pushed and time.monotonic() < deadline: | |
| time.sleep(0.01) | |
| assert media.pushed and len(media.pushed[0]) == 1600 | |
| stale_epoch = speaker.epoch | |
| speaker.interrupt() | |
| speaker.play(stale_epoch, np.ones(24_000, np.float32)) # late, old turn | |
| deadline = time.monotonic() + 2.0 | |
| while media.cleared == 0 and time.monotonic() < deadline: | |
| time.sleep(0.01) | |
| assert media.cleared == 1 | |
| time.sleep(0.2) | |
| assert len(media.pushed) == 1 # stale audio never reached the device | |
| finally: | |
| speaker.close() | |
| speaker.join(timeout=2) | |
| def test_speaker_reports_hard_interrupt_clear_completion(): | |
| media = FakeMedia() | |
| speaker = Speaker(media) | |
| speaker.start() | |
| try: | |
| epoch = speaker.interrupt() | |
| assert speaker.wait_flushed(epoch, timeout=2.0) | |
| assert media.cleared == 1 | |
| finally: | |
| speaker.close() | |
| speaker.join(timeout=2) | |