From 0374c800d0c6dc6347b3805fec450cce4ac423cc Mon Sep 17 00:00:00 2001 From: "wei.zhong1" Date: Wed, 8 Jul 2026 09:58:52 -0700 Subject: [PATCH] Add Krisp VIVA Turn as a user turn-stop strategy Wire pipecat's KrispVivaTurn (VIVA SDK turn detection v3) into the cascade pipeline as a selectable turn_stop_strategy alongside the existing LocalSmartTurnAnalyzerV3. The analyzer is imported lazily because it depends on the proprietary krisp_audio SDK plus a .kef model and license key, so environments without the SDK are unaffected until the strategy is selected. - turn_config: add 'krisp_viva_turn' branch forwarding threshold/ frame_duration_ms to KrispTurnParams and model_path/api_key/sample_rate to the KrispVivaTurn constructor (both fall back to env vars) - config + .env.example: document the strategy and required env vars (KRISP_VIVA_TURN_MODEL_PATH, KRISP_VIVA_API_KEY) - tests: cover the new branch via an injected fake krisp module - bump simulation_version 2.0.1 -> 2.0.2 --- .env.example | 11 +++- src/eva/__init__.py | 2 +- src/eva/assistant/pipeline/turn_config.py | 19 ++++++- src/eva/models/config.py | 4 +- tests/unit/assistant/test_turn_config.py | 68 ++++++++++++++++++++++- 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index c9031db0..6306cdd0 100644 --- a/.env.example +++ b/.env.example @@ -237,14 +237,23 @@ EVA_MODEL_LIST='[ #v EVA_MODEL__TURN_START_STRATEGY_PARAMS='{}' #i Turn stop strategy: when to consider the user has finished speaking. +#i krisp_viva_turn uses Krisp's VIVA SDK turn detection v3 (streaming, frame-by-frame). +#i It needs the proprietary krisp_audio SDK installed plus KRISP_VIVA_TURN_MODEL_PATH +#i (path to a .kef model file) and KRISP_VIVA_API_KEY set below. #d enum -#e turn_analyzer,speech_timeout,external +#e turn_analyzer,speech_timeout,krisp_viva_turn,external #v EVA_MODEL__TURN_STOP_STRATEGY=turn_analyzer #i Turn stop strategy parameters. For speech_timeout: {"user_speech_timeout": 0.8}. +#i For krisp_viva_turn: {"threshold": 0.5, "frame_duration_ms": 20} (model_path/api_key +#i can also be passed here to override the env vars). #d json_object #v EVA_MODEL__TURN_STOP_STRATEGY_PARAMS='{}' +#i Krisp VIVA SDK credentials (only used when TURN_STOP_STRATEGY=krisp_viva_turn). +#v KRISP_VIVA_TURN_MODEL_PATH=/path/to/krisp-viva-turn.kef +#v KRISP_VIVA_API_KEY=your_krisp_license_key + #i VAD (Voice Activity Detection) analyzer. #d enum #e silero,none diff --git a/src/eva/__init__.py b/src/eva/__init__.py index eb2eaa6a..1a6de9c3 100644 --- a/src/eva/__init__.py +++ b/src/eva/__init__.py @@ -7,7 +7,7 @@ # Bump simulation_version when changes affect benchmark outputs (agent code, # user simulator, orchestrator, simulation prompts, agent configs, tool mocks). -simulation_version = "2.0.1" +simulation_version = "2.0.2" # Bump metrics_version when changes affect metric computation (metrics code, # judge prompts, pricing tables, postprocessor). diff --git a/src/eva/assistant/pipeline/turn_config.py b/src/eva/assistant/pipeline/turn_config.py index e4ec3083..7208f638 100644 --- a/src/eva/assistant/pipeline/turn_config.py +++ b/src/eva/assistant/pipeline/turn_config.py @@ -94,7 +94,7 @@ def create_turn_stop_strategy( """Create a user turn stop strategy from configuration. Args: - strategy_type: Strategy type ('speech_timeout', 'turn_analyzer', 'external') + strategy_type: Strategy type ('speech_timeout', 'turn_analyzer', 'krisp_viva_turn', 'external') strategy_params: Strategy-specific parameters smart_turn_stop_secs: stop_secs for SmartTurnParams (used with turn_analyzer strategy) @@ -117,11 +117,26 @@ def create_turn_stop_strategy( smart_params = SmartTurnParams(stop_secs=stop_secs) if stop_secs is not None else None turn_analyzer = LocalSmartTurnAnalyzerV3(params=smart_params) return TurnAnalyzerUserTurnStopStrategy(turn_analyzer=turn_analyzer, **params) + elif strategy_type_lower == "krisp_viva_turn": + # KrispVivaTurn uses Krisp's VIVA SDK turn detection v3 (Tt) API, processing audio + # frame-by-frame in real time with an external VAD flag. It depends on the proprietary + # krisp_audio SDK plus a .kef model file (KRISP_VIVA_TURN_MODEL_PATH) and license key + # (KRISP_VIVA_API_KEY), so import lazily to keep the SDK optional. + from pipecat.audio.turn.krisp_viva_turn import KrispTurnParams, KrispVivaTurn + + params = dict(strategy_params) + # Krisp analyzer tuning params; fall back to KrispTurnParams defaults when absent. + krisp_kwargs = {k: params.pop(k) for k in ("threshold", "frame_duration_ms") if k in params} + krisp_params = KrispTurnParams(**krisp_kwargs) if krisp_kwargs else None + # Constructor kwargs; model_path/api_key fall back to their env vars when omitted. + analyzer_kwargs = {k: params.pop(k) for k in ("model_path", "api_key", "sample_rate") if k in params} + krisp_analyzer = KrispVivaTurn(params=krisp_params, **analyzer_kwargs) + return TurnAnalyzerUserTurnStopStrategy(turn_analyzer=krisp_analyzer, **params) elif strategy_type_lower == "external": # ExternalUserTurnStopStrategy has no required parameters return ExternalUserTurnStopStrategy(**strategy_params) else: raise ValueError( f"Unsupported turn stop strategy: {strategy_type}. " - f"Supported types: 'speech_timeout', 'turn_analyzer', 'external'" + f"Supported types: 'speech_timeout', 'turn_analyzer', 'krisp_viva_turn', 'external'" ) diff --git a/src/eva/models/config.py b/src/eva/models/config.py index ea797e8a..93b690c9 100644 --- a/src/eva/models/config.py +++ b/src/eva/models/config.py @@ -163,8 +163,10 @@ class ModelConfig(BaseModel): turn_stop_strategy: str = Field( "turn_analyzer", description=( - "User turn stop strategy: 'speech_timeout', 'turn_analyzer', or 'external'. " + "User turn stop strategy: 'speech_timeout', 'turn_analyzer', 'krisp_viva_turn', or 'external'. " "Defaults to 'turn_analyzer' (TurnAnalyzerUserTurnStopStrategy with LocalSmartTurnAnalyzerV3). " + "'krisp_viva_turn' uses Krisp's VIVA SDK (requires the krisp_audio SDK, " + "KRISP_VIVA_TURN_MODEL_PATH, and KRISP_VIVA_API_KEY). " "Set via EVA_MODEL__TURN_STOP_STRATEGY." ), ) diff --git a/tests/unit/assistant/test_turn_config.py b/tests/unit/assistant/test_turn_config.py index 72e135a6..a0b1370e 100644 --- a/tests/unit/assistant/test_turn_config.py +++ b/tests/unit/assistant/test_turn_config.py @@ -226,5 +226,71 @@ def test_unsupported_strategy_raises(self): def test_unsupported_strategy_error_lists_supported(self): """ValueError message lists supported strategies.""" - with pytest.raises(ValueError, match="speech_timeout.*turn_analyzer.*external"): + with pytest.raises(ValueError, match="speech_timeout.*turn_analyzer.*krisp_viva_turn.*external"): create_turn_stop_strategy("unknown", {}) + + +# --------------------------------------------------------------------------- +# create_turn_stop_strategy — krisp_viva_turn +# --------------------------------------------------------------------------- + + +class TestCreateTurnStopStrategyKrispViva: + """Tests for the 'krisp_viva_turn' branch of create_turn_stop_strategy. + + KrispVivaTurn depends on the proprietary ``krisp_audio`` SDK, which is not a + project dependency. The factory imports ``pipecat.audio.turn.krisp_viva_turn`` + lazily, so these tests inject a fake module into ``sys.modules``. + """ + + @staticmethod + def _install_fake_krisp(monkeypatch): + """Inject a fake pipecat.audio.turn.krisp_viva_turn module and return it.""" + import sys + + fake_module = MagicMock() + fake_module.KrispVivaTurn.return_value = MagicMock() + monkeypatch.setitem(sys.modules, "pipecat.audio.turn.krisp_viva_turn", fake_module) + return fake_module + + def test_krisp_viva_turn_strategy(self, monkeypatch): + """'krisp_viva_turn' returns a TurnAnalyzerUserTurnStopStrategy with a KrispVivaTurn.""" + fake_module = self._install_fake_krisp(monkeypatch) + + result = create_turn_stop_strategy("krisp_viva_turn", {}) + + assert isinstance(result, TurnAnalyzerUserTurnStopStrategy) + # No tuning params given -> KrispTurnParams left at its defaults (params=None). + fake_module.KrispVivaTurn.assert_called_once_with(params=None) + fake_module.KrispTurnParams.assert_not_called() + + def test_krisp_viva_turn_case_insensitive(self, monkeypatch): + """Strategy type is matched case-insensitively.""" + self._install_fake_krisp(monkeypatch) + assert isinstance(create_turn_stop_strategy("KRISP_VIVA_TURN", {}), TurnAnalyzerUserTurnStopStrategy) + + def test_krisp_viva_turn_tuning_params(self, monkeypatch): + """Tuning params threshold / frame_duration_ms are forwarded to KrispTurnParams.""" + fake_module = self._install_fake_krisp(monkeypatch) + + create_turn_stop_strategy("krisp_viva_turn", {"threshold": 0.7, "frame_duration_ms": 30}) + + fake_module.KrispTurnParams.assert_called_once_with(threshold=0.7, frame_duration_ms=30) + # The constructed params object is passed to the analyzer. + assert fake_module.KrispVivaTurn.call_args.kwargs["params"] is fake_module.KrispTurnParams.return_value + + def test_krisp_viva_turn_constructor_params(self, monkeypatch): + """model_path / api_key / sample_rate are forwarded to the KrispVivaTurn constructor.""" + fake_module = self._install_fake_krisp(monkeypatch) + + create_turn_stop_strategy( + "krisp_viva_turn", + {"model_path": "/models/turn.kef", "api_key": "secret", "sample_rate": 16000}, + ) + + call_kwargs = fake_module.KrispVivaTurn.call_args.kwargs + assert call_kwargs["model_path"] == "/models/turn.kef" + assert call_kwargs["api_key"] == "secret" + assert call_kwargs["sample_rate"] == 16000 + # None of these should leak into the strategy as tuning params. + fake_module.KrispTurnParams.assert_not_called()