rasa-pro 3.10.15__py3-none-any.whl → 3.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of rasa-pro might be problematic. Click here for more details.
- rasa/__main__.py +31 -15
- rasa/api.py +12 -2
- rasa/cli/arguments/default_arguments.py +24 -4
- rasa/cli/arguments/run.py +15 -0
- rasa/cli/arguments/shell.py +5 -1
- rasa/cli/arguments/train.py +17 -9
- rasa/cli/evaluate.py +7 -7
- rasa/cli/inspect.py +19 -7
- rasa/cli/interactive.py +1 -0
- rasa/cli/project_templates/calm/config.yml +5 -7
- rasa/cli/project_templates/calm/endpoints.yml +15 -2
- rasa/cli/project_templates/tutorial/config.yml +8 -5
- rasa/cli/project_templates/tutorial/data/flows.yml +1 -1
- rasa/cli/project_templates/tutorial/data/patterns.yml +5 -0
- rasa/cli/project_templates/tutorial/domain.yml +14 -0
- rasa/cli/project_templates/tutorial/endpoints.yml +5 -0
- rasa/cli/run.py +7 -0
- rasa/cli/scaffold.py +4 -2
- rasa/cli/studio/upload.py +0 -15
- rasa/cli/train.py +14 -53
- rasa/cli/utils.py +14 -11
- rasa/cli/x.py +7 -7
- rasa/constants.py +3 -1
- rasa/core/actions/action.py +77 -33
- rasa/core/actions/action_hangup.py +29 -0
- rasa/core/actions/action_repeat_bot_messages.py +89 -0
- rasa/core/actions/e2e_stub_custom_action_executor.py +5 -1
- rasa/core/actions/http_custom_action_executor.py +4 -0
- rasa/core/agent.py +2 -2
- rasa/core/brokers/kafka.py +3 -1
- rasa/core/brokers/pika.py +3 -1
- rasa/core/channels/__init__.py +10 -6
- rasa/core/channels/channel.py +41 -4
- rasa/core/channels/development_inspector.py +150 -46
- rasa/core/channels/inspector/README.md +1 -1
- rasa/core/channels/inspector/dist/assets/{arc-b6e548fe.js → arc-bc141fb2.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{c4Diagram-d0fbc5ce-fa03ac9e.js → c4Diagram-d0fbc5ce-be2db283.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{classDiagram-936ed81e-ee67392a.js → classDiagram-936ed81e-55366915.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{classDiagram-v2-c3cb15f1-9b283fae.js → classDiagram-v2-c3cb15f1-bb529518.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{createText-62fc7601-8b6fcc2a.js → createText-62fc7601-b0ec81d6.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{edges-f2ad444c-22e77f4f.js → edges-f2ad444c-6166330c.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{erDiagram-9d236eb7-60ffc87f.js → erDiagram-9d236eb7-5ccc6a8e.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{flowDb-1972c806-9dd802e4.js → flowDb-1972c806-fca3bfe4.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{flowDiagram-7ea5b25a-5fa1912f.js → flowDiagram-7ea5b25a-4739080f.js} +1 -1
- rasa/core/channels/inspector/dist/assets/flowDiagram-v2-855bc5b3-736177bf.js +1 -0
- rasa/core/channels/inspector/dist/assets/{flowchart-elk-definition-abe16c3d-622a1fd2.js → flowchart-elk-definition-abe16c3d-7c1b0e0f.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{ganttDiagram-9b5ea136-e285a63a.js → ganttDiagram-9b5ea136-772fd050.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{gitGraphDiagram-99d0ae7c-f237bdca.js → gitGraphDiagram-99d0ae7c-8eae1dc9.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{index-2c4b9a3b-4b03d70e.js → index-2c4b9a3b-f55afcdf.js} +1 -1
- rasa/core/channels/inspector/dist/assets/index-e7cef9de.js +1317 -0
- rasa/core/channels/inspector/dist/assets/{infoDiagram-736b4530-72a0fa5f.js → infoDiagram-736b4530-124d4a14.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{journeyDiagram-df861f2b-82218c41.js → journeyDiagram-df861f2b-7c4fae44.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{layout-78cff630.js → layout-b9885fb6.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{line-5038b469.js → line-7c59abb6.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{linear-c4fc4098.js → linear-4776f780.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{mindmap-definition-beec6740-c33c8ea6.js → mindmap-definition-beec6740-2332c46c.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{pieDiagram-dbbf0591-a8d03059.js → pieDiagram-dbbf0591-8fb39303.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{quadrantDiagram-4d7f4fd6-6a0e56b2.js → quadrantDiagram-4d7f4fd6-3c7180a2.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{requirementDiagram-6fc4c22a-2dc7c7bd.js → requirementDiagram-6fc4c22a-e910bcb8.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{sankeyDiagram-8f13d901-2360fe39.js → sankeyDiagram-8f13d901-ead16c89.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{sequenceDiagram-b655622a-41b9f9ad.js → sequenceDiagram-b655622a-29a02a19.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{stateDiagram-59f0c015-0aad326f.js → stateDiagram-59f0c015-042b3137.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{stateDiagram-v2-2b26beab-9847d984.js → stateDiagram-v2-2b26beab-2178c0f3.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{styles-080da4f6-564d890e.js → styles-080da4f6-23ffa4fc.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{styles-3dcbcfbf-38957613.js → styles-3dcbcfbf-94f59763.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{styles-9c745c82-f0fc6921.js → styles-9c745c82-78a6bebc.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{svgDrawCommon-4835440b-ef3c5a77.js → svgDrawCommon-4835440b-eae2a6f6.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{timeline-definition-5b62e21b-bf3e91c1.js → timeline-definition-5b62e21b-5c968d92.js} +1 -1
- rasa/core/channels/inspector/dist/assets/{xychartDiagram-2b33534f-4d4026c0.js → xychartDiagram-2b33534f-fd3db0d5.js} +1 -1
- rasa/core/channels/inspector/dist/index.html +18 -15
- rasa/core/channels/inspector/index.html +17 -14
- rasa/core/channels/inspector/package.json +5 -1
- rasa/core/channels/inspector/src/App.tsx +118 -68
- rasa/core/channels/inspector/src/components/Chat.tsx +95 -0
- rasa/core/channels/inspector/src/components/DiagramFlow.tsx +11 -10
- rasa/core/channels/inspector/src/components/DialogueStack.tsx +10 -25
- rasa/core/channels/inspector/src/components/LoadingSpinner.tsx +6 -3
- rasa/core/channels/inspector/src/helpers/audiostream.ts +165 -0
- rasa/core/channels/inspector/src/helpers/formatters.test.ts +10 -0
- rasa/core/channels/inspector/src/helpers/formatters.ts +107 -41
- rasa/core/channels/inspector/src/helpers/utils.ts +92 -7
- rasa/core/channels/inspector/src/types.ts +21 -1
- rasa/core/channels/inspector/yarn.lock +94 -1
- rasa/core/channels/rest.py +51 -46
- rasa/core/channels/socketio.py +28 -1
- rasa/core/channels/telegram.py +1 -1
- rasa/core/channels/twilio.py +1 -1
- rasa/core/channels/{audiocodes.py → voice_ready/audiocodes.py} +122 -69
- rasa/core/channels/{voice_aware → voice_ready}/jambonz.py +26 -8
- rasa/core/channels/{voice_aware → voice_ready}/jambonz_protocol.py +57 -5
- rasa/core/channels/{twilio_voice.py → voice_ready/twilio_voice.py} +64 -28
- rasa/core/channels/voice_ready/utils.py +37 -0
- rasa/core/channels/voice_stream/asr/__init__.py +0 -0
- rasa/core/channels/voice_stream/asr/asr_engine.py +89 -0
- rasa/core/channels/voice_stream/asr/asr_event.py +18 -0
- rasa/core/channels/voice_stream/asr/azure.py +129 -0
- rasa/core/channels/voice_stream/asr/deepgram.py +90 -0
- rasa/core/channels/voice_stream/audio_bytes.py +8 -0
- rasa/core/channels/voice_stream/browser_audio.py +107 -0
- rasa/core/channels/voice_stream/call_state.py +23 -0
- rasa/core/channels/voice_stream/tts/__init__.py +0 -0
- rasa/core/channels/voice_stream/tts/azure.py +106 -0
- rasa/core/channels/voice_stream/tts/cartesia.py +118 -0
- rasa/core/channels/voice_stream/tts/tts_cache.py +27 -0
- rasa/core/channels/voice_stream/tts/tts_engine.py +58 -0
- rasa/core/channels/voice_stream/twilio_media_streams.py +173 -0
- rasa/core/channels/voice_stream/util.py +57 -0
- rasa/core/channels/voice_stream/voice_channel.py +427 -0
- rasa/core/information_retrieval/qdrant.py +1 -0
- rasa/core/nlg/contextual_response_rephraser.py +45 -17
- rasa/{nlu → core}/persistor.py +203 -68
- rasa/core/policies/enterprise_search_policy.py +119 -63
- rasa/core/policies/flows/flow_executor.py +15 -22
- rasa/core/policies/intentless_policy.py +83 -28
- rasa/core/processor.py +25 -0
- rasa/core/run.py +12 -2
- rasa/core/secrets_manager/constants.py +4 -0
- rasa/core/secrets_manager/factory.py +8 -0
- rasa/core/secrets_manager/vault.py +11 -1
- rasa/core/training/interactive.py +33 -34
- rasa/core/utils.py +47 -21
- rasa/dialogue_understanding/coexistence/llm_based_router.py +41 -14
- rasa/dialogue_understanding/commands/__init__.py +6 -0
- rasa/dialogue_understanding/commands/repeat_bot_messages_command.py +60 -0
- rasa/dialogue_understanding/commands/session_end_command.py +61 -0
- rasa/dialogue_understanding/commands/user_silence_command.py +59 -0
- rasa/dialogue_understanding/commands/utils.py +5 -0
- rasa/dialogue_understanding/generator/constants.py +2 -0
- rasa/dialogue_understanding/generator/flow_retrieval.py +47 -9
- rasa/dialogue_understanding/generator/llm_based_command_generator.py +38 -15
- rasa/dialogue_understanding/generator/llm_command_generator.py +1 -1
- rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +35 -13
- rasa/dialogue_understanding/generator/single_step/command_prompt_template.jinja2 +3 -0
- rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +60 -13
- rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +53 -0
- rasa/dialogue_understanding/patterns/repeat.py +37 -0
- rasa/dialogue_understanding/patterns/user_silence.py +37 -0
- rasa/dialogue_understanding/processor/command_processor.py +21 -1
- rasa/e2e_test/aggregate_test_stats_calculator.py +1 -11
- rasa/e2e_test/assertions.py +136 -61
- rasa/e2e_test/assertions_schema.yml +23 -0
- rasa/e2e_test/e2e_test_case.py +85 -6
- rasa/e2e_test/e2e_test_runner.py +2 -3
- rasa/engine/graph.py +0 -1
- rasa/engine/loader.py +12 -0
- rasa/engine/recipes/config_files/default_config.yml +0 -3
- rasa/engine/recipes/default_recipe.py +0 -1
- rasa/engine/recipes/graph_recipe.py +0 -1
- rasa/engine/runner/dask.py +2 -2
- rasa/engine/storage/local_model_storage.py +12 -42
- rasa/engine/storage/storage.py +1 -5
- rasa/engine/validation.py +527 -74
- rasa/model_manager/__init__.py +0 -0
- rasa/model_manager/config.py +40 -0
- rasa/model_manager/model_api.py +559 -0
- rasa/model_manager/runner_service.py +286 -0
- rasa/model_manager/socket_bridge.py +146 -0
- rasa/model_manager/studio_jwt_auth.py +86 -0
- rasa/model_manager/trainer_service.py +325 -0
- rasa/model_manager/utils.py +87 -0
- rasa/model_manager/warm_rasa_process.py +187 -0
- rasa/model_service.py +112 -0
- rasa/model_training.py +42 -23
- rasa/nlu/tokenizers/whitespace_tokenizer.py +3 -14
- rasa/server.py +4 -2
- rasa/shared/constants.py +60 -8
- rasa/shared/core/constants.py +13 -0
- rasa/shared/core/domain.py +107 -50
- rasa/shared/core/events.py +29 -0
- rasa/shared/core/flows/flow.py +5 -0
- rasa/shared/core/flows/flows_list.py +19 -6
- rasa/shared/core/flows/flows_yaml_schema.json +10 -0
- rasa/shared/core/flows/utils.py +39 -0
- rasa/shared/core/flows/validation.py +121 -0
- rasa/shared/core/flows/yaml_flows_io.py +15 -27
- rasa/shared/core/slots.py +5 -0
- rasa/shared/importers/importer.py +59 -41
- rasa/shared/importers/multi_project.py +23 -11
- rasa/shared/importers/rasa.py +12 -3
- rasa/shared/importers/remote_importer.py +196 -0
- rasa/shared/importers/utils.py +3 -1
- rasa/shared/nlu/training_data/formats/rasa_yaml.py +18 -3
- rasa/shared/nlu/training_data/training_data.py +18 -19
- rasa/shared/providers/_configs/litellm_router_client_config.py +220 -0
- rasa/shared/providers/_configs/model_group_config.py +167 -0
- rasa/shared/providers/_configs/openai_client_config.py +1 -1
- rasa/shared/providers/_configs/rasa_llm_client_config.py +73 -0
- rasa/shared/providers/_configs/self_hosted_llm_client_config.py +1 -0
- rasa/shared/providers/_configs/utils.py +16 -0
- rasa/shared/providers/_utils.py +79 -0
- rasa/shared/providers/embedding/_base_litellm_embedding_client.py +13 -29
- rasa/shared/providers/embedding/azure_openai_embedding_client.py +54 -21
- rasa/shared/providers/embedding/default_litellm_embedding_client.py +24 -0
- rasa/shared/providers/embedding/litellm_router_embedding_client.py +135 -0
- rasa/shared/providers/llm/_base_litellm_client.py +34 -22
- rasa/shared/providers/llm/azure_openai_llm_client.py +50 -29
- rasa/shared/providers/llm/default_litellm_llm_client.py +24 -0
- rasa/shared/providers/llm/litellm_router_llm_client.py +182 -0
- rasa/shared/providers/llm/rasa_llm_client.py +112 -0
- rasa/shared/providers/llm/self_hosted_llm_client.py +5 -29
- rasa/shared/providers/mappings.py +19 -0
- rasa/shared/providers/router/__init__.py +0 -0
- rasa/shared/providers/router/_base_litellm_router_client.py +183 -0
- rasa/shared/providers/router/router_client.py +73 -0
- rasa/shared/utils/common.py +40 -24
- rasa/shared/utils/health_check/__init__.py +0 -0
- rasa/shared/utils/health_check/embeddings_health_check_mixin.py +31 -0
- rasa/shared/utils/health_check/health_check.py +258 -0
- rasa/shared/utils/health_check/llm_health_check_mixin.py +31 -0
- rasa/shared/utils/io.py +27 -6
- rasa/shared/utils/llm.py +353 -43
- rasa/shared/utils/schemas/events.py +2 -0
- rasa/shared/utils/schemas/model_config.yml +0 -10
- rasa/shared/utils/yaml.py +181 -38
- rasa/studio/data_handler.py +3 -1
- rasa/studio/upload.py +160 -74
- rasa/telemetry.py +94 -17
- rasa/tracing/config.py +3 -1
- rasa/tracing/instrumentation/attribute_extractors.py +95 -18
- rasa/tracing/instrumentation/instrumentation.py +121 -0
- rasa/utils/common.py +5 -0
- rasa/utils/endpoints.py +27 -1
- rasa/utils/io.py +8 -16
- rasa/utils/log_utils.py +9 -2
- rasa/utils/sanic_error_handler.py +32 -0
- rasa/validator.py +110 -4
- rasa/version.py +1 -1
- {rasa_pro-3.10.15.dist-info → rasa_pro-3.11.0.dist-info}/METADATA +14 -12
- {rasa_pro-3.10.15.dist-info → rasa_pro-3.11.0.dist-info}/RECORD +234 -183
- rasa/core/channels/inspector/dist/assets/flowDiagram-v2-855bc5b3-1844e5a5.js +0 -1
- rasa/core/channels/inspector/dist/assets/index-a5d3e69d.js +0 -1040
- rasa/core/channels/voice_aware/utils.py +0 -20
- rasa/llm_fine_tuning/notebooks/unsloth_finetuning.ipynb +0 -407
- /rasa/core/channels/{voice_aware → voice_ready}/__init__.py +0 -0
- /rasa/core/channels/{voice_native → voice_stream}/__init__.py +0 -0
- {rasa_pro-3.10.15.dist-info → rasa_pro-3.11.0.dist-info}/NOTICE +0 -0
- {rasa_pro-3.10.15.dist-info → rasa_pro-3.11.0.dist-info}/WHEEL +0 -0
- {rasa_pro-3.10.15.dist-info → rasa_pro-3.11.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from werkzeug.local import LocalProxy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Per voice session data
|
|
9
|
+
# This is similar to how flask makes the "request" object available as a global variable
|
|
10
|
+
# It's a "global" variable that is local to an async task (i.e. websocket session)
|
|
11
|
+
@dataclass
|
|
12
|
+
class CallState:
|
|
13
|
+
is_user_speaking: bool = False
|
|
14
|
+
is_bot_speaking: bool = False
|
|
15
|
+
silence_timeout_watcher: Optional[asyncio.Task] = None
|
|
16
|
+
silence_timeout: Optional[float] = None
|
|
17
|
+
latest_bot_audio_id: Optional[str] = None
|
|
18
|
+
should_hangup: bool = False
|
|
19
|
+
connection_failed: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_call_state: ContextVar[CallState] = ContextVar("call_state")
|
|
23
|
+
call_state = LocalProxy(_call_state)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import AsyncIterator, Dict, Optional
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import structlog
|
|
7
|
+
from aiohttp import ClientConnectorError, ClientTimeout
|
|
8
|
+
|
|
9
|
+
from rasa.core.channels.voice_stream.audio_bytes import RasaAudioBytes
|
|
10
|
+
from rasa.core.channels.voice_stream.tts.tts_engine import (
|
|
11
|
+
TTSEngine,
|
|
12
|
+
TTSEngineConfig,
|
|
13
|
+
TTSError,
|
|
14
|
+
)
|
|
15
|
+
from rasa.shared.constants import AZURE_SPEECH_API_KEY_ENV_VAR
|
|
16
|
+
from rasa.shared.exceptions import ConnectionException
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
structlogger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AzureTTSConfig(TTSEngineConfig):
|
|
24
|
+
speech_region: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AzureTTS(TTSEngine[AzureTTSConfig]):
|
|
28
|
+
session: Optional[aiohttp.ClientSession] = None
|
|
29
|
+
required_env_vars = (AZURE_SPEECH_API_KEY_ENV_VAR,)
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: Optional[AzureTTSConfig] = None):
|
|
32
|
+
super().__init__(config)
|
|
33
|
+
timeout = ClientTimeout(total=self.config.timeout)
|
|
34
|
+
# Have to create this class-shared session lazily at run time otherwise
|
|
35
|
+
# the async event loop doesn't work
|
|
36
|
+
if self.__class__.session is None or self.__class__.session.closed:
|
|
37
|
+
self.__class__.session = aiohttp.ClientSession(timeout=timeout)
|
|
38
|
+
|
|
39
|
+
async def synthesize(
|
|
40
|
+
self, text: str, config: Optional[AzureTTSConfig] = None
|
|
41
|
+
) -> AsyncIterator[RasaAudioBytes]:
|
|
42
|
+
"""Generate speech from text using a remote TTS system."""
|
|
43
|
+
config = self.config.merge(config)
|
|
44
|
+
azure_speech_url = self.get_tts_endpoint(config)
|
|
45
|
+
headers = self.get_request_headers()
|
|
46
|
+
body = self.create_request_body(text, config)
|
|
47
|
+
if self.session is None:
|
|
48
|
+
raise ConnectionException("Client session is not initialized")
|
|
49
|
+
try:
|
|
50
|
+
async with self.session.post(
|
|
51
|
+
azure_speech_url, headers=headers, data=body, chunked=True
|
|
52
|
+
) as response:
|
|
53
|
+
if 200 <= response.status < 300:
|
|
54
|
+
async for data in response.content.iter_chunked(1024):
|
|
55
|
+
yield self.engine_bytes_to_rasa_audio_bytes(data)
|
|
56
|
+
return
|
|
57
|
+
else:
|
|
58
|
+
structlogger.error(
|
|
59
|
+
"azure.synthesize.rest.failed",
|
|
60
|
+
status_code=response.status,
|
|
61
|
+
msg=response.text(),
|
|
62
|
+
)
|
|
63
|
+
raise TTSError(f"TTS failed: {response.text()}")
|
|
64
|
+
except ClientConnectorError as e:
|
|
65
|
+
raise TTSError(e)
|
|
66
|
+
except TimeoutError as e:
|
|
67
|
+
raise TTSError(e)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def get_request_headers() -> dict[str, str]:
|
|
71
|
+
azure_speech_api_key = os.environ[AZURE_SPEECH_API_KEY_ENV_VAR]
|
|
72
|
+
return {
|
|
73
|
+
"Ocp-Apim-Subscription-Key": azure_speech_api_key,
|
|
74
|
+
"Content-Type": "application/ssml+xml",
|
|
75
|
+
"X-Microsoft-OutputFormat": "raw-8khz-8bit-mono-mulaw",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def get_tts_endpoint(config: AzureTTSConfig) -> str:
|
|
80
|
+
return f"https://{config.speech_region}.tts.speech.microsoft.com/cognitiveservices/v1"
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def create_request_body(text: str, conf: AzureTTSConfig) -> str:
|
|
84
|
+
return f"""
|
|
85
|
+
<speak version='1.0' xml:lang='{conf.language}'>
|
|
86
|
+
<voice xml:lang='{conf.language}' name='{conf.voice}'>
|
|
87
|
+
{text}
|
|
88
|
+
</voice>
|
|
89
|
+
</speak>"""
|
|
90
|
+
|
|
91
|
+
def engine_bytes_to_rasa_audio_bytes(self, chunk: bytes) -> RasaAudioBytes:
|
|
92
|
+
"""Convert the generated tts audio bytes into rasa audio bytes."""
|
|
93
|
+
return RasaAudioBytes(chunk)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def get_default_config() -> AzureTTSConfig:
|
|
97
|
+
return AzureTTSConfig(
|
|
98
|
+
language="en-US",
|
|
99
|
+
voice="en-US-JennyNeural",
|
|
100
|
+
timeout=10,
|
|
101
|
+
speech_region="germanywestcentral",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_config_dict(cls, config: Dict) -> "AzureTTS":
|
|
106
|
+
return cls(AzureTTSConfig.from_dict(config))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import AsyncIterator, Dict, Optional
|
|
3
|
+
import os
|
|
4
|
+
import aiohttp
|
|
5
|
+
import structlog
|
|
6
|
+
from aiohttp import ClientConnectorError, ClientTimeout
|
|
7
|
+
|
|
8
|
+
from rasa.core.channels.voice_stream.tts.tts_engine import (
|
|
9
|
+
TTSEngineConfig,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from rasa.core.channels.voice_stream.audio_bytes import HERTZ, RasaAudioBytes
|
|
13
|
+
from rasa.core.channels.voice_stream.tts.tts_engine import TTSEngine, TTSError
|
|
14
|
+
from rasa.shared.constants import CARTESIA_API_KEY_ENV_VAR
|
|
15
|
+
from rasa.shared.exceptions import ConnectionException
|
|
16
|
+
|
|
17
|
+
structlogger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CartesiaTTSConfig(TTSEngineConfig):
|
|
22
|
+
model_id: Optional[str] = None
|
|
23
|
+
version: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CartesiaTTS(TTSEngine[CartesiaTTSConfig]):
|
|
27
|
+
session: Optional[aiohttp.ClientSession] = None
|
|
28
|
+
required_env_vars = (CARTESIA_API_KEY_ENV_VAR,)
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: Optional[CartesiaTTSConfig] = None):
|
|
31
|
+
super().__init__(config)
|
|
32
|
+
timeout = ClientTimeout(total=self.config.timeout)
|
|
33
|
+
# Have to create this class-shared session lazily at run time otherwise
|
|
34
|
+
# the async event loop doesn't work
|
|
35
|
+
if self.__class__.session is None or self.__class__.session.closed:
|
|
36
|
+
self.__class__.session = aiohttp.ClientSession(timeout=timeout)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def get_tts_endpoint() -> str:
|
|
40
|
+
"""Create the endpoint string for cartesia."""
|
|
41
|
+
return "https://api.cartesia.ai/tts/bytes"
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def get_request_body(text: str, config: CartesiaTTSConfig) -> Dict:
|
|
45
|
+
"""Create the request body for cartesia."""
|
|
46
|
+
# more info on payload:
|
|
47
|
+
# https://docs.cartesia.ai/reference/api-reference/rest/stream-speech-bytes
|
|
48
|
+
return {
|
|
49
|
+
"model_id": config.model_id,
|
|
50
|
+
"transcript": text,
|
|
51
|
+
"language": config.language,
|
|
52
|
+
"voice": {
|
|
53
|
+
"mode": "id",
|
|
54
|
+
"id": config.voice,
|
|
55
|
+
},
|
|
56
|
+
"output_format": {
|
|
57
|
+
"container": "raw",
|
|
58
|
+
"encoding": "pcm_mulaw",
|
|
59
|
+
"sample_rate": HERTZ,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def get_request_headers(config: CartesiaTTSConfig) -> dict[str, str]:
|
|
65
|
+
cartesia_api_key = os.environ[CARTESIA_API_KEY_ENV_VAR]
|
|
66
|
+
return {
|
|
67
|
+
"Cartesia-Version": str(config.version),
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"X-API-Key": str(cartesia_api_key),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async def synthesize(
|
|
73
|
+
self, text: str, config: Optional[CartesiaTTSConfig] = None
|
|
74
|
+
) -> AsyncIterator[RasaAudioBytes]:
|
|
75
|
+
"""Generate speech from text using a remote TTS system."""
|
|
76
|
+
config = self.config.merge(config)
|
|
77
|
+
payload = self.get_request_body(text, config)
|
|
78
|
+
headers = self.get_request_headers(config)
|
|
79
|
+
url = self.get_tts_endpoint()
|
|
80
|
+
if self.session is None:
|
|
81
|
+
raise ConnectionException("Client session is not initialized")
|
|
82
|
+
try:
|
|
83
|
+
async with self.session.post(
|
|
84
|
+
url, headers=headers, json=payload, chunked=True
|
|
85
|
+
) as response:
|
|
86
|
+
if 200 <= response.status < 300:
|
|
87
|
+
async for data in response.content.iter_chunked(1024):
|
|
88
|
+
yield self.engine_bytes_to_rasa_audio_bytes(data)
|
|
89
|
+
return
|
|
90
|
+
else:
|
|
91
|
+
structlogger.error(
|
|
92
|
+
"cartesia.synthesize.rest.failed",
|
|
93
|
+
status_code=response.status,
|
|
94
|
+
msg=response.text(),
|
|
95
|
+
)
|
|
96
|
+
raise TTSError(f"TTS failed: {response.text()}")
|
|
97
|
+
except ClientConnectorError as e:
|
|
98
|
+
raise TTSError(e)
|
|
99
|
+
except TimeoutError as e:
|
|
100
|
+
raise TTSError(e)
|
|
101
|
+
|
|
102
|
+
def engine_bytes_to_rasa_audio_bytes(self, chunk: bytes) -> RasaAudioBytes:
|
|
103
|
+
"""Convert the generated tts audio bytes into rasa audio bytes."""
|
|
104
|
+
return RasaAudioBytes(chunk)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def get_default_config() -> CartesiaTTSConfig:
|
|
108
|
+
return CartesiaTTSConfig(
|
|
109
|
+
language="en",
|
|
110
|
+
voice="248be419-c632-4f23-adf1-5324ed7dbf1d",
|
|
111
|
+
timeout=10,
|
|
112
|
+
model_id="sonic-english",
|
|
113
|
+
version="2024-06-10",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_config_dict(cls, config: Dict) -> "CartesiaTTS":
|
|
118
|
+
return cls(CartesiaTTSConfig.from_dict(config))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
import logging
|
|
4
|
+
from rasa.core.channels.voice_stream.audio_bytes import RasaAudioBytes
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TTSCache:
|
|
10
|
+
"""An LRU Cache for TTS based on pythons OrderedDict."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, max_size: int):
|
|
13
|
+
self.cache: OrderedDict[str, RasaAudioBytes] = OrderedDict()
|
|
14
|
+
self.max_size = max_size
|
|
15
|
+
|
|
16
|
+
def get(self, text: str) -> Optional[RasaAudioBytes]:
|
|
17
|
+
if text not in self.cache:
|
|
18
|
+
return None
|
|
19
|
+
else:
|
|
20
|
+
self.cache.move_to_end(text)
|
|
21
|
+
return self.cache[text]
|
|
22
|
+
|
|
23
|
+
def put(self, text: str, audio_bytes: RasaAudioBytes) -> None:
|
|
24
|
+
self.cache[text] = audio_bytes
|
|
25
|
+
self.cache.move_to_end(text)
|
|
26
|
+
if len(self.cache) > self.max_size:
|
|
27
|
+
self.cache.popitem(last=False)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import AsyncIterator, Dict, Generic, Optional, Tuple, Type, TypeVar
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from rasa.core.channels.voice_stream.audio_bytes import RasaAudioBytes
|
|
5
|
+
from rasa.core.channels.voice_stream.util import MergeableConfig
|
|
6
|
+
from rasa.shared.exceptions import RasaException
|
|
7
|
+
from rasa.shared.utils.common import validate_environment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TTSError(RasaException):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound="TTSEngineConfig")
|
|
15
|
+
E = TypeVar("E", bound="TTSEngine")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TTSEngineConfig(MergeableConfig):
|
|
20
|
+
language: Optional[str] = None
|
|
21
|
+
voice: Optional[str] = None
|
|
22
|
+
timeout: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TTSEngine(Generic[T]):
|
|
26
|
+
required_env_vars: Tuple[str, ...] = ()
|
|
27
|
+
required_packages: Tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Optional[T] = None):
|
|
30
|
+
self.config = self.get_default_config().merge(config)
|
|
31
|
+
validate_environment(
|
|
32
|
+
self.required_env_vars,
|
|
33
|
+
self.required_packages,
|
|
34
|
+
f"TTS Engine {self.__class__.__name__}",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def close_connection(self) -> None:
|
|
38
|
+
"""Cleanup the connection if necessary."""
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
async def synthesize(
|
|
42
|
+
self, text: str, config: Optional[T] = None
|
|
43
|
+
) -> AsyncIterator[RasaAudioBytes]:
|
|
44
|
+
"""Generate speech from text using a remote TTS system."""
|
|
45
|
+
yield RasaAudioBytes(b"")
|
|
46
|
+
|
|
47
|
+
def engine_bytes_to_rasa_audio_bytes(self, chunk: bytes) -> RasaAudioBytes:
|
|
48
|
+
"""Convert the generated tts audio bytes into rasa audio bytes."""
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def get_default_config() -> T:
|
|
53
|
+
"""Get the default config for this component."""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_config_dict(cls: Type[E], config: Dict) -> E:
|
|
58
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Text, Tuple
|
|
7
|
+
|
|
8
|
+
from sanic import Blueprint, HTTPResponse, Request, response
|
|
9
|
+
from sanic import Websocket # type: ignore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from rasa.core.channels import UserMessage
|
|
13
|
+
from rasa.core.channels.voice_ready.utils import CallParameters
|
|
14
|
+
from rasa.core.channels.voice_stream.call_state import call_state
|
|
15
|
+
from rasa.core.channels.voice_stream.tts.tts_engine import TTSEngine
|
|
16
|
+
from rasa.core.channels.voice_stream.audio_bytes import RasaAudioBytes
|
|
17
|
+
from rasa.core.channels.voice_stream.voice_channel import (
|
|
18
|
+
EndConversationAction,
|
|
19
|
+
NewAudioAction,
|
|
20
|
+
VoiceChannelAction,
|
|
21
|
+
ContinueConversationAction,
|
|
22
|
+
VoiceInputChannel,
|
|
23
|
+
VoiceOutputChannel,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = structlog.get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def map_call_params(data: Dict[Text, Any]) -> CallParameters:
|
|
30
|
+
"""Map the twilio stream parameters to the CallParameters dataclass."""
|
|
31
|
+
stream_sid = data["streamSid"]
|
|
32
|
+
parameters = data["start"]["customParameters"]
|
|
33
|
+
return CallParameters(
|
|
34
|
+
call_id=parameters.get("call_id", ""),
|
|
35
|
+
user_phone=parameters.get("user_phone", ""),
|
|
36
|
+
bot_phone=parameters.get("bot_phone", ""),
|
|
37
|
+
direction=parameters.get("direction"),
|
|
38
|
+
stream_id=stream_sid,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TwilioMediaStreamsOutputChannel(VoiceOutputChannel):
|
|
43
|
+
@classmethod
|
|
44
|
+
def name(cls) -> str:
|
|
45
|
+
return "twilio_media_streams"
|
|
46
|
+
|
|
47
|
+
def rasa_audio_bytes_to_channel_bytes(
|
|
48
|
+
self, rasa_audio_bytes: RasaAudioBytes
|
|
49
|
+
) -> bytes:
|
|
50
|
+
return base64.b64encode(rasa_audio_bytes)
|
|
51
|
+
|
|
52
|
+
def create_marker_message(self, recipient_id: str) -> Tuple[str, str]:
|
|
53
|
+
message_id = uuid.uuid4().hex
|
|
54
|
+
mark_message = json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"event": "mark",
|
|
57
|
+
"streamSid": recipient_id,
|
|
58
|
+
"mark": {"name": message_id},
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
return mark_message, message_id
|
|
62
|
+
|
|
63
|
+
def channel_bytes_to_message(self, recipient_id: str, channel_bytes: bytes) -> str:
|
|
64
|
+
media_message = json.dumps(
|
|
65
|
+
{
|
|
66
|
+
"event": "media",
|
|
67
|
+
"streamSid": recipient_id,
|
|
68
|
+
"media": {
|
|
69
|
+
"payload": channel_bytes.decode("utf-8"),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return media_message
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TwilioMediaStreamsInputChannel(VoiceInputChannel):
|
|
77
|
+
@classmethod
|
|
78
|
+
def name(cls) -> str:
|
|
79
|
+
return "twilio_media_streams"
|
|
80
|
+
|
|
81
|
+
def channel_bytes_to_rasa_audio_bytes(self, input_bytes: bytes) -> RasaAudioBytes:
|
|
82
|
+
return RasaAudioBytes(base64.b64decode(input_bytes))
|
|
83
|
+
|
|
84
|
+
async def collect_call_parameters(
|
|
85
|
+
self, channel_websocket: Websocket
|
|
86
|
+
) -> Optional[CallParameters]:
|
|
87
|
+
async for message in channel_websocket:
|
|
88
|
+
data = json.loads(message)
|
|
89
|
+
if data["event"] == "start":
|
|
90
|
+
# retrieve parameters set in the webhook - contains info about the
|
|
91
|
+
# caller
|
|
92
|
+
return map_call_params(data)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def map_input_message(
|
|
96
|
+
self,
|
|
97
|
+
message: Any,
|
|
98
|
+
) -> VoiceChannelAction:
|
|
99
|
+
data = json.loads(message)
|
|
100
|
+
if data["event"] == "media":
|
|
101
|
+
audio_bytes = self.channel_bytes_to_rasa_audio_bytes(
|
|
102
|
+
data["media"]["payload"]
|
|
103
|
+
)
|
|
104
|
+
return NewAudioAction(audio_bytes)
|
|
105
|
+
elif data["event"] == "stop":
|
|
106
|
+
return EndConversationAction()
|
|
107
|
+
elif data["event"] == "mark":
|
|
108
|
+
if data["mark"]["name"] == call_state.latest_bot_audio_id:
|
|
109
|
+
# Just finished streaming last audio bytes
|
|
110
|
+
call_state.is_bot_speaking = False # type: ignore[attr-defined]
|
|
111
|
+
if call_state.should_hangup:
|
|
112
|
+
logger.debug(
|
|
113
|
+
"twilio_streams.hangup", marker=call_state.latest_bot_audio_id
|
|
114
|
+
)
|
|
115
|
+
return EndConversationAction()
|
|
116
|
+
else:
|
|
117
|
+
call_state.is_bot_speaking = True # type: ignore[attr-defined]
|
|
118
|
+
return ContinueConversationAction()
|
|
119
|
+
|
|
120
|
+
def create_output_channel(
|
|
121
|
+
self, voice_websocket: Websocket, tts_engine: TTSEngine
|
|
122
|
+
) -> VoiceOutputChannel:
|
|
123
|
+
return TwilioMediaStreamsOutputChannel(
|
|
124
|
+
voice_websocket,
|
|
125
|
+
tts_engine,
|
|
126
|
+
self.tts_cache,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def websocket_stream_url(self) -> str:
|
|
130
|
+
"""Returns the websocket stream URL."""
|
|
131
|
+
# depending on the config value, the url might contain http as a
|
|
132
|
+
# protocol or not - we'll make sure both work
|
|
133
|
+
if self.server_url.startswith("http"):
|
|
134
|
+
base_url = self.server_url.replace("http", "ws")
|
|
135
|
+
else:
|
|
136
|
+
base_url = f"wss://{self.server_url}"
|
|
137
|
+
return f"{base_url}/webhooks/twilio_media_streams/websocket"
|
|
138
|
+
|
|
139
|
+
def blueprint(
|
|
140
|
+
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
141
|
+
) -> Blueprint:
|
|
142
|
+
"""Defines a Sanic bluelogger.debug."""
|
|
143
|
+
blueprint = Blueprint("twilio_media_streams", __name__)
|
|
144
|
+
|
|
145
|
+
@blueprint.route("/", methods=["GET"])
|
|
146
|
+
async def health(_: Request) -> HTTPResponse:
|
|
147
|
+
return response.json({"status": "ok"})
|
|
148
|
+
|
|
149
|
+
@blueprint.route("/webhook", methods=["POST"])
|
|
150
|
+
async def receive(request: Request) -> HTTPResponse:
|
|
151
|
+
from twilio.twiml.voice_response import Connect, VoiceResponse
|
|
152
|
+
|
|
153
|
+
voice_response = VoiceResponse()
|
|
154
|
+
start = Connect()
|
|
155
|
+
stream = start.stream(url=self.websocket_stream_url())
|
|
156
|
+
# pass information about the call to the webhook - so we can
|
|
157
|
+
# store it in the input channel
|
|
158
|
+
stream.parameter(name="call_id", value=request.form.get("CallSid", None))
|
|
159
|
+
stream.parameter(name="user_phone", value=request.form.get("From", None))
|
|
160
|
+
stream.parameter(name="bot_phone", value=request.form.get("To", None))
|
|
161
|
+
stream.parameter(
|
|
162
|
+
name="direction", value=request.form.get("Direction", None)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
voice_response.append(start)
|
|
166
|
+
|
|
167
|
+
return response.text(str(voice_response), content_type="text/xml")
|
|
168
|
+
|
|
169
|
+
@blueprint.websocket("/websocket") # type: ignore
|
|
170
|
+
async def handle_message(request: Request, ws: Websocket) -> None:
|
|
171
|
+
await self.run_audio_streaming(on_new_message, ws)
|
|
172
|
+
|
|
173
|
+
return blueprint
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import wave
|
|
2
|
+
import audioop
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Optional, Type, TypeVar
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from rasa.core.channels.voice_stream.audio_bytes import HERTZ, RasaAudioBytes
|
|
9
|
+
from rasa.shared.exceptions import RasaException
|
|
10
|
+
|
|
11
|
+
structlogger = structlog.get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_wav_to_rasa_audio_bytes(file_name: str) -> Optional[RasaAudioBytes]:
|
|
15
|
+
"""Reads rasa audio bytes from a file."""
|
|
16
|
+
if not file_name.endswith(".wav"):
|
|
17
|
+
raise RasaException("Should only read .wav files with this method.")
|
|
18
|
+
wave_object = wave.open(file_name, "rb")
|
|
19
|
+
wave_data = wave_object.readframes(wave_object.getnframes())
|
|
20
|
+
if wave_object.getnchannels() != 1:
|
|
21
|
+
wave_data = audioop.tomono(wave_data, wave_object.getsampwidth(), 1, 1)
|
|
22
|
+
if wave_object.getsampwidth() != 1:
|
|
23
|
+
wave_data = audioop.lin2lin(wave_data, wave_object.getsampwidth(), 1)
|
|
24
|
+
# 8 bit is unsigned
|
|
25
|
+
# wave_data = audioop.bias(wave_data, 1, 128)
|
|
26
|
+
if wave_object.getframerate() != HERTZ:
|
|
27
|
+
wave_data, _ = audioop.ratecv(
|
|
28
|
+
wave_data, 1, 1, wave_object.getframerate(), HERTZ, None
|
|
29
|
+
)
|
|
30
|
+
wave_data = audioop.lin2ulaw(wave_data, 1)
|
|
31
|
+
return RasaAudioBytes(wave_data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_silence(length_in_seconds: float = 1.0) -> RasaAudioBytes:
|
|
35
|
+
return RasaAudioBytes(b"\00" * int(length_in_seconds * HERTZ))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T", bound="MergeableConfig")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MergeableConfig:
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def merge(self: T, other: Optional[T]) -> T:
|
|
47
|
+
"""Merges two configs while dropping None values of the second config."""
|
|
48
|
+
if other is None:
|
|
49
|
+
return self
|
|
50
|
+
other_dict = asdict(other)
|
|
51
|
+
other_dict_clean = {k: v for k, v in other_dict.items() if v is not None}
|
|
52
|
+
merged = {**asdict(self), **other_dict_clean}
|
|
53
|
+
return self.from_dict(merged)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls: Type[T], data: dict[str, Optional[str]]) -> T:
|
|
57
|
+
return cls(**data)
|