import pytest from livekit.agents.utils.exp_filter import ExpFilter from livekit.agents.voice.endpointing import DynamicEndpointing, create_endpointing pytestmark = pytest.mark.unit class TestExponentialMovingAverage: """Test cases for the ExponentialMovingAverage class.""" def test_initialization_with_valid_alpha(self) -> None: """Test that EMA initializes correctly with valid alpha values.""" ema = ExpFilter(alpha=0.5) assert ema.value is None ema_with_initial = ExpFilter(alpha=0.5, initial=10.0) assert ema_with_initial.value == 10.0 ema = ExpFilter(alpha=1.0) assert ema.value is None def test_initialization_with_invalid_alpha(self) -> None: """Test with invalid alpha values.""" with pytest.raises(ValueError, match="alpha must be in"): ExpFilter(alpha=0.0) with pytest.raises(ValueError, match="alpha must be in"): ExpFilter(alpha=-0.5) with pytest.raises(ValueError, match="alpha must be in"): ExpFilter(alpha=1.5) def test_update_with_no_initial_value(self) -> None: """Test that first update sets the value directly.""" ema = ExpFilter(alpha=0.5) result = ema.apply(1.0, 10.0) assert result == 10.0 assert ema.value == 10.0 def test_update_with_initial_value(self) -> None: """Test that update applies EMA formula when initial value exists.""" ema = ExpFilter(alpha=0.5, initial=10.0) result = ema.apply(1.0, 20.0) # 0.5 * 20 + 0.5 * 10 = 10 + 5 = 15 assert result == 15.0 assert ema.value == 15.0 def test_update_multiple_times(self) -> None: """Test multiple updates calculate correctly.""" ema = ExpFilter(alpha=0.5, initial=10.0) ema.apply(1.0, 20.0) # 15.0 ema.apply(1.0, 20.0) # 0.5 * 20 + 0.5 * 15 = 17.5 assert ema.value == 17.5 def test_reset(self) -> None: ema = ExpFilter(alpha=0.5, initial=10.0) assert ema.value == 10.0 ema.reset() assert ema.value == 10.0 ema = ExpFilter(alpha=0.5, initial=10.0) ema.reset(initial=5.0) assert ema.value == 5.0 class TestDynamicEndpointing: """Test cases for the DynamicEndpointing class.""" def test_initialization(self) -> None: """Test basic initialization with min and max delay.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) assert ep.min_delay == 0.3 assert ep.max_delay == 1.0 def test_initialization_with_custom_alpha(self) -> None: """Test initialization with custom alpha value.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.2) assert ep.min_delay == 0.3 assert ep.max_delay == 1.0 def test_initialization_uses_updated_default_alpha(self) -> None: ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) assert ep._utterance_pause._alpha == pytest.approx(0.9, rel=1e-5) assert ep._turn_pause._alpha == pytest.approx(0.9, rel=1e-5) def test_empty_delays(self) -> None: """Test between_utterance_delay returns 0 when no utterances recorded.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) assert ep.between_utterance_delay == 0.0 assert ep.between_turn_delay == 0.0 assert ep.immediate_interruption_delay == (0.0, 0.0) def test_on_utterance_ended(self) -> None: ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_end_of_speech(ended_at=100.0) assert ep._utterance_ended_at == 100.0 ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_end_of_speech(ended_at=99.9) assert ep._utterance_ended_at == 99.9 def test_on_utterance_started(self) -> None: ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_start_of_speech(started_at=100.0) assert ep._utterance_started_at == 100.0 def test_on_agent_speech_started(self) -> None: ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_start_of_agent_speech(started_at=100.0) assert ep._agent_speech_started_at == 100.0 def test_between_utterance_delay_calculation(self) -> None: """Test between_utterance_delay calculates the gap between utterances.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_speech(started_at=100.5) assert ep.between_utterance_delay == pytest.approx(0.5, rel=1e-5) def test_between_turn_delay_calculation(self) -> None: """Test between_turn_delay calculates gap between utterance end and agent speech.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.8) assert ep.between_turn_delay == pytest.approx(0.8, rel=1e-5) def test_pause_between_utterances_updates_min_delay(self) -> None: """Test that pauses between utterances (case 1) update min_delay via EMA.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) initial_min = ep.min_delay ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_speech(started_at=100.4) ep.on_end_of_speech(ended_at=100.5, should_ignore=False) # min_delay should be updated via EMA: 0.5 * 0.4 + 0.5 * 0.3 = 0.35 expected = 0.5 * 0.4 + 0.5 * initial_min assert ep.min_delay == pytest.approx(expected, rel=1e-5) def test_new_turn_updates_max_delay(self) -> None: """Test that new turns (case 3) update max_delay via EMA.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.6) ep.on_start_of_speech(started_at=101.5) ep.on_end_of_speech(ended_at=102.0, should_ignore=False) assert ep.max_delay == pytest.approx(0.5 * 0.6 + 0.5 * 1.0, rel=1e-5) def test_interruption_updates_min_delay(self) -> None: """Test that immediate interruptions (case 2) update min_delay.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.2) assert ep._agent_speech_started_at is not None ep.on_start_of_speech(started_at=100.25, overlapping=True) assert ep._overlapping is True ep.on_end_of_speech(ended_at=100.5) # pause = 100.25 - 100.0 = 0.25 # EMA: 0.5 * max(0.25, 0.3) + 0.5 * 0.3 = 0.3 assert ep._overlapping is False assert ep._agent_speech_started_at is None # already used assert ep.min_delay == pytest.approx(0.3, rel=1e-5) def test_update_options(self) -> None: ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.update_options(min_delay=0.5) assert ep.min_delay == 0.5 assert ep._min_delay == 0.5 ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.update_options(max_delay=2.0) assert ep.max_delay == 2.0 assert ep._max_delay == 2.0 ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.update_options(min_delay=0.5, max_delay=2.0) assert ep.min_delay == 0.5 assert ep.max_delay == 2.0 ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.update_options() assert ep.min_delay == 0.3 assert ep.max_delay == 1.0 def test_max_delay_clamped_to_configured_max(self) -> None: """Test that max_delay updates are clamped to the configured maximum.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=1.0) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=102.0) ep.on_start_of_speech(started_at=105.0) assert ep.max_delay == 1.0 # pause=2.0 clamped to _max_delay def test_max_delay_clamped_to_min_delay(self) -> None: """Test that max_delay updates are clamped to at least min_delay.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=1.0) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.1) ep.on_start_of_speech(started_at=100.5) assert ep.max_delay >= ep._min_delay def test_non_interruption_clears_agent_speech(self) -> None: """Test that non-interruption utterance start clears agent speech timestamp.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.5) assert ep._agent_speech_started_at is not None ep.on_start_of_speech(started_at=102.0) ep.on_end_of_speech(ended_at=103.0, should_ignore=False) assert ep._agent_speech_started_at is None def test_consecutive_interruptions_only_track_first(self) -> None: """Test that only the first interruption in a sequence updates min_delay.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.2) ep.on_start_of_speech(started_at=100.25, overlapping=True) assert ep._overlapping is True prev_val = ep.min_delay, ep.max_delay ep.on_start_of_speech(started_at=100.35) assert ep._overlapping is True assert prev_val == (ep.min_delay, ep.max_delay) def test_delayed_interruption_updates_max_delay_without_crashing(self) -> None: """Delayed interruptions should update max delay via the EMA path.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.9) ep.on_start_of_speech(started_at=101.8) ep.on_end_of_speech(ended_at=102.0, should_ignore=False) assert ep.max_delay == pytest.approx(0.5 * 0.9 + 0.5 * 1.0, rel=1e-5) def test_interruption_adjusts_stale_utterance_end_time(self) -> None: """Interruption path should adjust stale utterance end timestamp before delay updates.""" ep = DynamicEndpointing(min_delay=0.06, max_delay=1.0, alpha=1.0) # Simulate stale ordering where end timestamp still belongs to a previous utterance. ep.on_end_of_speech(ended_at=99.0) ep.on_start_of_speech(started_at=100.0) ep.on_start_of_agent_speech(started_at=100.2) ep.on_start_of_speech(started_at=100.25, overlapping=True) assert ep._utterance_ended_at == pytest.approx(100.2, rel=1e-3) assert ep.min_delay == pytest.approx(0.06, rel=1e-5) assert ep.max_delay == pytest.approx(1.0, rel=1e-5) def test_update_options_preserves_filter_alpha(self) -> None: """Changing delays should not overwrite the EMA smoothing coefficient.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.update_options(min_delay=0.6, max_delay=2.0) assert ep._utterance_pause._alpha == pytest.approx(0.5, rel=1e-5) assert ep._turn_pause._alpha == pytest.approx(0.5, rel=1e-5) def test_update_options_updates_alpha_in_place(self) -> None: """update_options(alpha=...) should update both EMA filters without resetting learned state.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) # Record some history so the filter has a non-initial value. ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_speech(started_at=100.2) ep.on_end_of_speech(ended_at=101.0) learned_min = ep.min_delay ep.update_options(alpha=0.2) assert ep._utterance_pause._alpha == pytest.approx(0.2, rel=1e-5) assert ep._turn_pause._alpha == pytest.approx(0.2, rel=1e-5) # Learned value should be preserved — only the coefficient changed. assert ep.min_delay == pytest.approx(learned_min, rel=1e-5) def test_update_options_updates_filter_clamp_bounds(self) -> None: """Changing delays should propagate into exp-filter min/max clamp limits.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.update_options(min_delay=0.5, max_delay=2.0) assert ep._utterance_pause._min_val == 0.5 assert ep._turn_pause._max_val == 2.0 # min_delay updated from 0.3 to 0.5 ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_speech(started_at=100.2) assert ep.min_delay == pytest.approx(0.5, rel=1e-5) # max_delay updated from 1.0 to 2.0 ep.on_end_of_speech(ended_at=101.0) ep.on_start_of_agent_speech(started_at=102.8) ep.on_start_of_speech(started_at=103.5) assert 1.0 < ep.max_delay <= 2.0 def test_should_ignore_skips_filter_update(self) -> None: """should_ignore=True with overlapping=True skips EMA updates and resets state.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.5) # user starts 1.0s after agent (well outside 0.25s grace period) ep.on_start_of_speech(started_at=101.5, overlapping=True) prev_min = ep.min_delay prev_max = ep.max_delay ep.on_end_of_speech(ended_at=101.8, should_ignore=True) # filters should not have been updated assert ep.min_delay == prev_min assert ep.max_delay == prev_max # state should be reset assert ep._utterance_started_at is None assert ep._utterance_ended_at is None assert ep._overlapping is False assert ep._speaking is False def test_should_ignore_without_overlapping_still_updates(self) -> None: """should_ignore=True but overlapping=False follows the normal update path.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) initial_min = ep.min_delay ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_speech(started_at=100.4, overlapping=False) ep.on_end_of_speech(ended_at=100.6, should_ignore=True) # should_ignore only gates when overlapping, so min_delay should update (case 1) expected = 0.5 * 0.4 + 0.5 * initial_min assert ep.min_delay == pytest.approx(expected, rel=1e-5) def test_should_ignore_grace_period_overrides(self) -> None: """User speech within grace period of agent speech overrides should_ignore=True.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.5) # user starts speaking 0.1s after agent (within 0.25s grace period) ep.on_start_of_speech(started_at=100.6, overlapping=True) ep.on_end_of_speech(ended_at=100.8, should_ignore=True) # grace period should override should_ignore, so the interruption path runs # and state is properly cleaned up (not left as None) assert ep._utterance_ended_at == 100.8 assert ep._speaking is False def test_should_ignore_outside_grace_period(self) -> None: """User speech well after agent speech start is outside grace period.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.5) # user starts speaking 0.5s after agent (outside 0.25s grace period) ep.on_start_of_speech(started_at=101.0, overlapping=True) prev_min = ep.min_delay prev_max = ep.max_delay ep.on_end_of_speech(ended_at=101.5, should_ignore=True) # outside grace period, should_ignore takes effect — no filter update assert ep.min_delay == prev_min assert ep.max_delay == prev_max assert ep._utterance_started_at is None assert ep._utterance_ended_at is None def test_on_end_of_agent_speech_clears_state(self) -> None: """on_end_of_agent_speech sets ended_at, preserves started_at, clears overlapping.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) ep.on_start_of_agent_speech(started_at=100.0) ep.on_start_of_speech(started_at=100.1, overlapping=True) assert ep._overlapping is True assert ep._agent_speech_started_at == 100.0 ep.on_end_of_agent_speech(ended_at=101.0) assert ep._agent_speech_ended_at == 101.0 # _agent_speech_started_at is intentionally preserved so that # between_turn_delay can be computed in the normal end-of-speech path assert ep._agent_speech_started_at == 100.0 assert ep._overlapping is False def test_overlapping_inferred_from_agent_speech(self) -> None: """When _agent_speech_started_at is set, on_end_of_speech takes the interruption path.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) ep.on_end_of_speech(ended_at=100.0) ep.on_start_of_agent_speech(started_at=100.9) # overlapping not explicitly set ep.on_start_of_speech(started_at=101.8, overlapping=False) ep.on_end_of_speech(ended_at=102.0) # _agent_speech_started_at is set → interruption path → case 3 (delayed) updates max_delay # between_turn_delay = 100.9 - 100.0 = 0.9 assert ep.max_delay == pytest.approx(0.5 * 0.9 + 0.5 * 1.0, rel=1e-5) def test_speaking_flag_set_and_cleared(self) -> None: """_speaking is True after on_start_of_speech, False after on_end_of_speech.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0) assert ep._speaking is False ep.on_start_of_speech(started_at=100.0) assert ep._speaking is True ep.on_end_of_speech(ended_at=100.5) assert ep._speaking is False @pytest.mark.parametrize( "label, agent_speech, overlapping, should_ignore, within_grace, expect_min_change, expect_max_change", [ # --- No agent speech --- # Case 1: pause between utterances updates min_delay ("no_agent/no_overlap/no_ignore", "none", False, False, False, True, False), # should_ignore is ignored when not overlapping ("no_agent/no_overlap/ignore", "none", False, True, False, True, False), # --- Agent speech ended (on_end_of_agent_speech called) --- # agent finished speaking → normal path, between_turn_delay > 0 → case 3 updates max ("agent_ended/no_overlap/no_ignore", "ended", False, False, False, False, True), ("agent_ended/no_overlap/ignore", "ended", False, True, False, False, True), # --- Agent speech active --- # Inferred interruption from agent_speech_started_at → case 3 (delayed) ("agent_active/no_overlap/no_ignore", "active", False, False, False, False, True), # should_ignore ignored when not _overlapping ("agent_active/no_overlap/ignore", "active", False, True, False, False, True), # Explicit overlapping, immediate → case 2 updates min_delay ("agent_active/overlap/no_ignore", "active", True, False, False, True, False), # Backchannel: overlapping + should_ignore outside grace → skip ( "agent_active/overlap/ignore/outside_grace", "active", True, True, False, False, False, ), # Grace period override: overlapping + should_ignore inside grace → case 2 still runs ("agent_active/overlap/ignore/inside_grace", "active", True, True, True, True, False), ], ) def test_all_overlapping_and_should_ignore_combos( self, label: str, agent_speech: str, overlapping: bool, should_ignore: bool, within_grace: bool, expect_min_change: bool, expect_max_change: bool, ) -> None: """Exhaustive test of all agent_speech × overlapping × should_ignore combinations.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) # Previous utterance ep.on_start_of_speech(started_at=99.0) ep.on_end_of_speech(ended_at=100.0) # Set up agent speech state if agent_speech == "ended": ep.on_start_of_agent_speech(started_at=100.5) ep.on_end_of_agent_speech(ended_at=101.0) user_start = 101.5 elif agent_speech == "active": if within_grace: # Agent at 100.15, user at 100.35 (0.2s after agent, within 0.25s grace) # between_turn_delay=0.15, between_utterance_delay=0.35 # interruption_delay=|0.35-0.15|=0.2 <= 0.3 → case 2 triggers # EMA: 0.5*0.35 + 0.5*0.3 = 0.325 → min changes ep.on_start_of_agent_speech(started_at=100.15) user_start = 100.35 elif overlapping and should_ignore: # Outside grace: agent at 100.2, user at 101.5 (1.3s after agent) # should_ignore + overlapping + outside grace → skip ep.on_start_of_agent_speech(started_at=100.2) user_start = 101.5 elif overlapping: # Agent at 100.15, user at 100.4 (0.25s after agent, at grace boundary) # between_turn_delay=0.15, between_utterance_delay=0.4 # interruption_delay=|0.4-0.15|=0.25 <= 0.3 → case 2 triggers # EMA: 0.5*0.4 + 0.5*0.3 = 0.35 → min changes ep.on_start_of_agent_speech(started_at=100.15) user_start = 100.4 else: # Delayed: agent spoke but user starts much later (inferred interruption) # between_turn_delay=0.9 → case 3 updates max_delay ep.on_start_of_agent_speech(started_at=100.9) user_start = 101.8 else: # No agent speech user_start = 100.4 ep.on_start_of_speech(started_at=user_start, overlapping=overlapping) prev_min = ep.min_delay prev_max = ep.max_delay ep.on_end_of_speech(ended_at=user_start + 0.5, should_ignore=should_ignore) min_changed = ep.min_delay != prev_min max_changed = ep.max_delay != prev_max assert min_changed == expect_min_change, ( f"[{label}] min_delay {'should' if expect_min_change else 'should not'} change: " f"{prev_min} -> {ep.min_delay}" ) assert max_changed == expect_max_change, ( f"[{label}] max_delay {'should' if expect_max_change else 'should not'} change: " f"{prev_max} -> {ep.max_delay}" ) # State should always be cleaned up after on_end_of_speech assert ep._speaking is False, f"[{label}] _speaking should be False" assert ep._overlapping is False, f"[{label}] _overlapping should be False" def test_full_conversation_sequence(self) -> None: """Simulate a realistic multi-turn conversation with backchannel ignored.""" ep = DynamicEndpointing(min_delay=0.3, max_delay=1.0, alpha=0.5) # Turn 1: user speaks ep.on_start_of_speech(started_at=100.0) ep.on_end_of_speech(ended_at=101.0) # Agent responds ep.on_start_of_agent_speech(started_at=101.5) # Turn 2: user backchannel (ignored) — overlapping with agent, 1.0s after agent start ep.on_start_of_speech(started_at=102.5, overlapping=True) min_before_backchannel = ep.min_delay max_before_backchannel = ep.max_delay ep.on_end_of_speech(ended_at=102.8, should_ignore=True) # backchannel ignored — delays unchanged assert ep.min_delay == min_before_backchannel assert ep.max_delay == max_before_backchannel # Agent finishes ep.on_end_of_agent_speech(ended_at=103.0) # Turn 3: user speaks again (new turn after agent) ep.on_start_of_speech(started_at=103.5) ep.on_end_of_speech(ended_at=104.0) # agent_speech_ended_at was set, agent_speech_started_at is None # This is a normal (non-interruption) path # No agent_speech_started_at → case 1 (between utterances) doesn't apply # because _utterance_ended_at was reset to None by the ignored backchannel # so between_utterance_delay = 0 → no update assert ep._speaking is False assert ep._agent_speech_started_at is None class TestCreateEndpointing: def test_dynamic_mode_wires_alpha(self) -> None: """create_endpointing should pass alpha through to both EMA filters.""" ep = create_endpointing( {"mode": "dynamic", "min_delay": 0.3, "max_delay": 1.0, "alpha": 0.7} ) assert isinstance(ep, DynamicEndpointing) assert ep._utterance_pause._alpha == pytest.approx(0.7, rel=1e-5) assert ep._turn_pause._alpha == pytest.approx(0.7, rel=1e-5) def test_fixed_mode_returns_base_endpointing(self) -> None: ep = create_endpointing({"mode": "fixed", "min_delay": 0.5, "max_delay": 3.0, "alpha": 0.9}) assert not isinstance(ep, DynamicEndpointing) assert ep.min_delay == 0.5 assert ep.max_delay == 3.0