smallestai 3.1.0__py3-none-any.whl → 4.0.1__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 smallestai might be problematic. Click here for more details.

Files changed (135) hide show
  1. smallestai/__init__.py +35 -45
  2. smallestai/atoms/__init__.py +249 -123
  3. smallestai/atoms/api/__init__.py +0 -1
  4. smallestai/atoms/api/agent_templates_api.py +26 -26
  5. smallestai/atoms/api/agents_api.py +1316 -190
  6. smallestai/atoms/api/calls_api.py +29 -29
  7. smallestai/atoms/api/campaigns_api.py +165 -165
  8. smallestai/atoms/api/knowledge_base_api.py +290 -290
  9. smallestai/atoms/api/logs_api.py +13 -13
  10. smallestai/atoms/api/organization_api.py +13 -13
  11. smallestai/atoms/api/user_api.py +13 -13
  12. smallestai/atoms/atoms_client.py +77 -49
  13. smallestai/atoms/models/__init__.py +103 -43
  14. smallestai/atoms/models/agent_agent_id_webhook_subscriptions_delete200_response.py +89 -0
  15. smallestai/atoms/models/{get_agent_templates200_response.py → agent_agent_id_webhook_subscriptions_get200_response.py} +7 -7
  16. smallestai/atoms/models/agent_agent_id_webhook_subscriptions_get404_response.py +89 -0
  17. smallestai/atoms/models/agent_agent_id_webhook_subscriptions_post201_response.py +89 -0
  18. smallestai/atoms/models/agent_agent_id_webhook_subscriptions_post400_response.py +89 -0
  19. smallestai/atoms/models/agent_agent_id_webhook_subscriptions_post_request.py +97 -0
  20. smallestai/atoms/models/agent_dto.py +8 -6
  21. smallestai/atoms/models/agent_dto_language.py +17 -3
  22. smallestai/atoms/models/agent_dto_language_switching.py +95 -0
  23. smallestai/atoms/models/agent_dto_synthesizer.py +1 -1
  24. smallestai/atoms/models/{create_agent_from_template200_response.py → agent_from_template_post200_response.py} +4 -4
  25. smallestai/atoms/models/{get_agents200_response.py → agent_get200_response.py} +7 -7
  26. smallestai/atoms/models/{get_agents200_response_data.py → agent_get200_response_data.py} +9 -13
  27. smallestai/atoms/models/{delete_agent200_response.py → agent_id_delete200_response.py} +4 -4
  28. smallestai/atoms/models/{get_agent_by_id200_response.py → agent_id_get200_response.py} +4 -4
  29. smallestai/atoms/models/{update_agent200_response.py → agent_id_patch200_response.py} +4 -4
  30. smallestai/atoms/models/{update_agent_request.py → agent_id_patch_request.py} +17 -15
  31. smallestai/atoms/models/{update_agent_request_language.py → agent_id_patch_request_language.py} +14 -10
  32. smallestai/atoms/models/agent_id_patch_request_language_switching.py +96 -0
  33. smallestai/atoms/models/{update_agent_request_synthesizer.py → agent_id_patch_request_synthesizer.py} +6 -6
  34. smallestai/atoms/models/{update_agent_request_synthesizer_voice_config.py → agent_id_patch_request_synthesizer_voice_config.py} +27 -27
  35. smallestai/atoms/models/{update_agent_request_synthesizer_voice_config_one_of.py → agent_id_patch_request_synthesizer_voice_config_one_of.py} +4 -4
  36. smallestai/atoms/models/{update_agent_request_synthesizer_voice_config_one_of1.py → agent_id_patch_request_synthesizer_voice_config_one_of1.py} +4 -4
  37. smallestai/atoms/models/{get_campaign_by_id200_response.py → agent_id_workflow_get200_response.py} +7 -7
  38. smallestai/atoms/models/agent_id_workflow_get200_response_data.py +105 -0
  39. smallestai/atoms/models/agent_id_workflow_get200_response_data_edges_inner.py +127 -0
  40. smallestai/atoms/models/agent_id_workflow_get200_response_data_edges_inner_data.py +91 -0
  41. smallestai/atoms/models/agent_id_workflow_get200_response_data_edges_inner_marker_end.py +91 -0
  42. smallestai/atoms/models/agent_id_workflow_get200_response_data_nodes_inner.py +114 -0
  43. smallestai/atoms/models/agent_id_workflow_get200_response_data_nodes_inner_data.py +115 -0
  44. smallestai/atoms/models/agent_id_workflow_get200_response_data_nodes_inner_data_variables.py +97 -0
  45. smallestai/atoms/models/agent_id_workflow_get200_response_data_nodes_inner_data_variables_data_inner.py +91 -0
  46. smallestai/atoms/models/agent_id_workflow_get200_response_data_nodes_inner_position.py +89 -0
  47. smallestai/atoms/models/agent_id_workflow_get404_response.py +89 -0
  48. smallestai/atoms/models/agent_template_get200_response.py +97 -0
  49. smallestai/atoms/models/{get_agent_templates200_response_data_inner.py → agent_template_get200_response_data_inner.py} +6 -6
  50. smallestai/atoms/models/{get_campaigns200_response.py → audience_get200_response.py} +7 -7
  51. smallestai/atoms/models/{create_campaign201_response_data.py → audience_get200_response_data_inner.py} +16 -18
  52. smallestai/atoms/models/audience_id_delete200_response.py +89 -0
  53. smallestai/atoms/models/audience_id_delete400_response.py +89 -0
  54. smallestai/atoms/models/{get_current_user200_response.py → audience_id_get200_response.py} +7 -7
  55. smallestai/atoms/models/audience_id_get400_response.py +89 -0
  56. smallestai/atoms/models/audience_id_get403_response.py +89 -0
  57. smallestai/atoms/models/audience_id_get404_response.py +89 -0
  58. smallestai/atoms/models/audience_id_members_delete200_response.py +93 -0
  59. smallestai/atoms/models/audience_id_members_delete200_response_data.py +87 -0
  60. smallestai/atoms/models/audience_id_members_delete_request.py +87 -0
  61. smallestai/atoms/models/audience_id_members_get200_response.py +93 -0
  62. smallestai/atoms/models/audience_id_members_get200_response_data.py +101 -0
  63. smallestai/atoms/models/{get_campaigns200_response_data_inner_audience.py → audience_id_members_get200_response_data_members_inner.py} +8 -8
  64. smallestai/atoms/models/audience_id_members_get400_response.py +89 -0
  65. smallestai/atoms/models/audience_id_members_get500_response.py +89 -0
  66. smallestai/atoms/models/audience_id_members_post200_response.py +97 -0
  67. smallestai/atoms/models/audience_id_members_post200_response_data_inner.py +93 -0
  68. smallestai/atoms/models/audience_id_members_post200_response_data_inner_data.py +89 -0
  69. smallestai/atoms/models/audience_id_members_post400_response.py +89 -0
  70. smallestai/atoms/models/audience_id_members_post403_response.py +89 -0
  71. smallestai/atoms/models/audience_id_members_post_request.py +87 -0
  72. smallestai/atoms/models/audience_id_members_search_get200_response.py +93 -0
  73. smallestai/atoms/models/audience_id_members_search_get200_response_data.py +101 -0
  74. smallestai/atoms/models/audience_id_members_search_get200_response_data_search_info.py +103 -0
  75. smallestai/atoms/models/audience_id_members_search_get400_response.py +89 -0
  76. smallestai/atoms/models/audience_id_members_search_get500_response.py +89 -0
  77. smallestai/atoms/models/{create_campaign201_response.py → audience_post200_response.py} +7 -7
  78. smallestai/atoms/models/audience_post200_response_data.py +104 -0
  79. smallestai/atoms/models/audience_post400_response.py +89 -0
  80. smallestai/atoms/models/campaign_get200_response.py +93 -0
  81. smallestai/atoms/models/campaign_get200_response_data.py +87 -0
  82. smallestai/atoms/models/{get_campaigns_request.py → campaign_get_request.py} +4 -4
  83. smallestai/atoms/models/campaign_id_get200_response.py +93 -0
  84. smallestai/atoms/models/{get_campaign_by_id200_response_data.py → campaign_id_get200_response_data.py} +4 -4
  85. smallestai/atoms/models/campaign_post201_response.py +89 -0
  86. smallestai/atoms/models/{create_campaign_request.py → campaign_post_request.py} +4 -4
  87. smallestai/atoms/models/{start_outbound_call200_response.py → conversation_id_get200_response.py} +7 -7
  88. smallestai/atoms/models/{get_conversation_logs200_response_data.py → conversation_id_get200_response_data.py} +4 -4
  89. smallestai/atoms/models/conversation_outbound_post200_response.py +93 -0
  90. smallestai/atoms/models/{start_outbound_call200_response_data.py → conversation_outbound_post200_response_data.py} +4 -4
  91. smallestai/atoms/models/{start_outbound_call_request.py → conversation_outbound_post_request.py} +4 -4
  92. smallestai/atoms/models/create_agent_request.py +10 -6
  93. smallestai/atoms/models/create_agent_request_language.py +11 -7
  94. smallestai/atoms/models/create_agent_request_language_synthesizer_voice_config.py +24 -24
  95. smallestai/atoms/models/{knowledge_base_dto.py → knowledge_base.py} +15 -8
  96. smallestai/atoms/models/{knowledge_base_item_dto.py → knowledge_base_item.py} +19 -17
  97. smallestai/atoms/models/{get_knowledge_bases200_response.py → knowledgebase_get200_response.py} +7 -7
  98. smallestai/atoms/models/{get_knowledge_base_by_id200_response.py → knowledgebase_id_get200_response.py} +7 -7
  99. smallestai/atoms/models/{get_knowledge_base_items200_response.py → knowledgebase_id_items_get200_response.py} +7 -7
  100. smallestai/atoms/models/{upload_text_to_knowledge_base_request.py → knowledgebase_id_items_upload_text_post_request.py} +4 -4
  101. smallestai/atoms/models/{create_knowledge_base201_response.py → knowledgebase_post201_response.py} +4 -4
  102. smallestai/atoms/models/{create_knowledge_base_request.py → knowledgebase_post_request.py} +4 -4
  103. smallestai/atoms/models/{get_organization200_response.py → organization_get200_response.py} +7 -7
  104. smallestai/atoms/models/{get_organization200_response_data.py → organization_get200_response_data.py} +10 -10
  105. smallestai/atoms/models/{get_organization200_response_data_members_inner.py → organization_get200_response_data_members_inner.py} +4 -4
  106. smallestai/atoms/models/{get_organization200_response_data_subscription.py → organization_get200_response_data_subscription.py} +4 -4
  107. smallestai/atoms/models/product_phone_numbers_get200_response.py +97 -0
  108. smallestai/atoms/models/product_phone_numbers_get200_response_data_inner.py +100 -0
  109. smallestai/atoms/models/product_phone_numbers_get200_response_data_inner_attributes.py +89 -0
  110. smallestai/atoms/models/user_get200_response.py +93 -0
  111. smallestai/atoms/models/{get_current_user200_response_data.py → user_get200_response_data.py} +4 -4
  112. smallestai/atoms/models/webhook.py +124 -0
  113. smallestai/atoms/models/{get_campaigns200_response_data_inner_agent.py → webhook_agent.py} +8 -6
  114. smallestai/atoms/models/webhook_event.py +98 -0
  115. smallestai/atoms/models/webhook_get200_response.py +93 -0
  116. smallestai/atoms/models/webhook_get200_response_data.py +140 -0
  117. smallestai/atoms/models/webhook_id_delete404_response.py +89 -0
  118. smallestai/atoms/models/webhook_post201_response.py +89 -0
  119. smallestai/atoms/models/webhook_post_request.py +99 -0
  120. smallestai/atoms/models/webhook_post_request_events_inner.py +99 -0
  121. smallestai/atoms/models/webhook_subscription.py +108 -0
  122. smallestai/atoms/models/webhook_subscription_populated.py +112 -0
  123. smallestai/waves/__init__.py +2 -2
  124. smallestai/waves/async_waves_client.py +42 -69
  125. smallestai/waves/stream_tts.py +189 -254
  126. smallestai/waves/utils.py +3 -49
  127. smallestai/waves/waves_client.py +41 -69
  128. {smallestai-3.1.0.dist-info → smallestai-4.0.1.dist-info}/METADATA +3 -2
  129. smallestai-4.0.1.dist-info/RECORD +147 -0
  130. {smallestai-3.1.0.dist-info → smallestai-4.0.1.dist-info}/WHEEL +1 -1
  131. smallestai/atoms/models/get_campaigns200_response_data_inner.py +0 -118
  132. smallestai/atoms/models/get_conversation_logs200_response.py +0 -93
  133. smallestai-3.1.0.dist-info/RECORD +0 -87
  134. {smallestai-3.1.0.dist-info → smallestai-4.0.1.dist-info}/licenses/LICENSE +0 -0
  135. {smallestai-3.1.0.dist-info → smallestai-4.0.1.dist-info}/top_level.txt +0 -0
@@ -1,272 +1,207 @@
1
- import asyncio
1
+ import json
2
+ import base64
2
3
  import time
3
- from threading import Thread
4
- from queue import Queue, Empty
5
- from typing import AsyncGenerator, Optional, Union, List, Dict, Any
6
-
7
- from smallestai.waves.waves_client import WavesClient
8
- from smallestai.waves.exceptions import APIError
9
- from smallestai.waves.async_waves_client import AsyncWavesClient
10
- from smallestai.waves.utils import SENTENCE_END_REGEX
11
-
12
- class TextToAudioStream:
13
- def __init__(
14
- self,
15
- tts_instance: Union[WavesClient, AsyncWavesClient],
16
- queue_timeout: Optional[float] = 5.0,
17
- max_retries: Optional[int] = 3,
18
- verbose: bool = False
19
- ):
20
- """
21
- A real-time text-to-speech processor that converts streaming text into audio output.
22
- Useful for applications requiring immediate audio feedback from text generation,
23
- such as voice assistants, live captioning, or interactive chatbots.
24
-
25
- ⚠️ `add_wav_header` is disabled by default for streaming efficiency. Refer to the README for more information.
26
-
27
- Features:
28
- - Streams audio chunks as soon as text is available.
29
- - Handles both sync and async text-to-speech engines.
30
- - Automatically retries failed synthesis attempts.
31
- - Low latency between text generation and speech output.
32
-
33
- Args:
34
- tts_instance: The text-to-speech engine to use (Smallest or AsyncSmallest)
35
- queue_timeout: How long to wait for new text (seconds, default: 1.0)
36
- max_retries: Number of retry attempts for failed synthesis (default: 3)
37
- verbose: Whether to log detailed metrics about TTS requests (default: False)
38
- """
39
- self.tts_instance = tts_instance
40
- self.tts_instance.opts.add_wav_header = False
41
- self.sentence_end_regex = SENTENCE_END_REGEX
42
- self.queue_timeout = queue_timeout
43
- self.max_retries = max_retries
44
- self.queue = Queue()
45
- self.buffer_size = 250
46
- self.stop_flag = False
47
- self.verbose = verbose
4
+ import threading
5
+ import queue
6
+ from typing import Generator
7
+ from dataclasses import dataclass
8
+ from websocket import WebSocketApp
9
+
10
+ @dataclass
11
+ class TTSConfig:
12
+ voice_id: str
13
+ api_key: str
14
+ language: str = "en"
15
+ sample_rate: int = 24000
16
+ speed: float = 1.0
17
+ consistency: float = 0.5
18
+ enhancement: int = 1
19
+ similarity: float = 0
20
+ max_buffer_flush_ms: int = 0
21
+
22
+ class WavesStreamingTTS:
23
+ def __init__(self, config: TTSConfig):
24
+ self.config = config
25
+ self.ws_url = "wss://waves-api.smallest.ai/api/v1/lightning-v2/get_speech/stream"
26
+ self.ws = None
27
+ self.audio_queue = queue.Queue()
28
+ self.error_queue = queue.Queue()
29
+ self.is_complete = False
30
+ self.is_connected = False
31
+ self.request_id = None
48
32
 
49
- # Metrics tracking
50
- self.request_count = 0
51
- self.request_logs: List[Dict[str, Any]] = []
52
- self.start_time = 0
53
- self.first_api_response_time = None
54
- self.end_time = 0
55
-
56
- if self.tts_instance.opts.model == 'lightning-large':
57
- self.buffer_size = 140
58
-
59
-
60
- async def _stream_llm_output(self, llm_output: AsyncGenerator[str, None]) -> None:
61
- """
62
- Streams the LLM output, splitting it into chunks based on sentence boundaries
63
- or space characters if no sentence boundary is found before reaching buffer_size.
64
-
65
- Parameters:
66
- - llm_output (AsyncGenerator[str, None]): An async generator yielding LLM output.
67
- """
68
- buffer = ""
69
-
70
- async for chunk in llm_output:
71
- buffer += chunk
72
-
73
- while len(buffer) > self.buffer_size:
74
- chunk_text = buffer[:self.buffer_size]
75
- last_break_index = -1
76
-
77
- # Find last sentence boundary using regex
78
- for i in range(len(chunk_text) - 1, -1, -1):
79
- if self.sentence_end_regex.match(chunk_text[:i + 1]):
80
- last_break_index = i
81
- break
82
-
83
- if last_break_index == -1:
84
- # Fallback to space if no sentence boundary found
85
- last_space = chunk_text.rfind(' ')
86
- if last_space != -1:
87
- last_break_index = last_space
88
- else:
89
- last_break_index = self.buffer_size - 1
90
-
91
- # Add chunk to queue and update buffer
92
- self.queue.put(f'{buffer[:last_break_index + 1].replace("—", " ").strip()} ')
93
- buffer = buffer[last_break_index + 1:].strip()
94
-
95
- # Don't forget the remaining text
96
- if buffer:
97
- self.queue.put(f'{buffer.replace("—", " ").strip()} ')
98
-
99
- self.stop_flag = True
100
-
101
-
102
- def _synthesize_sync(self, sentence: str, retries: int = 0) -> Optional[bytes]:
103
- """Synchronously synthesizes a given sentence."""
104
- request_start_time = time.time()
105
- request_id = self.request_count + 1
33
+ def _get_headers(self):
34
+ return [f"Authorization: Bearer {self.config.api_key}"]
35
+
36
+ def _create_payload(self, text: str, continue_stream: bool = False, flush: bool = False):
37
+ return {
38
+ "voice_id": self.config.voice_id,
39
+ "text": text,
40
+ "language": self.config.language,
41
+ "sample_rate": self.config.sample_rate,
42
+ "speed": self.config.speed,
43
+ "consistency": self.config.consistency,
44
+ "similarity": self.config.similarity,
45
+ "enhancement": self.config.enhancement,
46
+ "max_buffer_flush_ms": self.config.max_buffer_flush_ms,
47
+ "continue": continue_stream,
48
+ "flush": flush
49
+ }
50
+
51
+ def _on_open(self, ws):
52
+ self.is_connected = True
106
53
 
54
+ def _on_message(self, ws, message):
107
55
  try:
108
- audio_content = self.tts_instance.synthesize(sentence)
109
- self.request_count += 1
110
- request_end_time = time.time()
56
+ data = json.loads(message)
57
+ status = data.get("status", "")
111
58
 
112
- if self.verbose:
113
- request_duration = request_end_time - request_start_time
114
- if self.first_api_response_time is None:
115
- self.first_api_response_time = time.time() - self.start_time
59
+ if status == "error":
60
+ self.error_queue.put(Exception(data.get("message", "Unknown error")))
61
+ return
116
62
 
117
- self.request_logs.append({
118
- "id": request_id,
119
- "text": sentence,
120
- "start_time": request_start_time - self.start_time,
121
- "end_time": request_end_time - self.start_time,
122
- "duration": request_duration,
123
- "char_count": len(sentence),
124
- "retries": retries
125
- })
63
+ if not self.request_id:
64
+ self.request_id = data.get("request_id")
126
65
 
127
- return audio_content
128
- except APIError as e:
129
- if retries < self.max_retries:
130
- if self.verbose:
131
- print(f"Retry {retries + 1}/{self.max_retries} for request: '{sentence[:30]}...'")
132
- return self._synthesize_sync(sentence, retries + 1)
133
- else:
134
- if self.verbose:
135
- print(f"Synthesis failed for sentence: {sentence} - Error: {e}. Retries Exhausted, for more information, visit https://waves.smallest.ai/")
136
- return None
66
+ audio_b64 = data.get("data", {}).get("audio")
67
+ if audio_b64:
68
+ self.audio_queue.put(base64.b64decode(audio_b64))
69
+
70
+ if status == "complete":
71
+ self.is_complete = True
72
+ self.audio_queue.put(None)
73
+
74
+ except Exception as e:
75
+ self.error_queue.put(e)
76
+
77
+ def _on_error(self, ws, error):
78
+ self.error_queue.put(error)
79
+
80
+ def _on_close(self, ws, *args):
81
+ self.is_connected = False
82
+ if not self.is_complete:
83
+ self.audio_queue.put(None)
84
+
85
+ def _connect(self):
86
+ if self.ws:
87
+ self.ws.close()
137
88
 
138
-
139
- async def _synthesize_async(self, sentence: str, retries: int = 0) -> Optional[bytes]:
140
- """Asynchronously synthesizes a given sentence."""
141
- request_start_time = time.time()
142
- request_id = self.request_count + 1
89
+ self.ws = WebSocketApp(
90
+ self.ws_url,
91
+ header=self._get_headers(),
92
+ on_open=self._on_open,
93
+ on_message=self._on_message,
94
+ on_error=self._on_error,
95
+ on_close=self._on_close
96
+ )
143
97
 
144
- try:
145
- audio_content = await self.tts_instance.synthesize(sentence)
146
- self.request_count += 1
147
- request_end_time = time.time()
98
+ ws_thread = threading.Thread(target=self.ws.run_forever)
99
+ ws_thread.daemon = True
100
+ ws_thread.start()
101
+
102
+ timeout = 5.0
103
+ start_time = time.time()
104
+ while not self.is_connected and time.time() - start_time < timeout:
105
+ time.sleep(0.1)
148
106
 
149
- if self.verbose:
150
- request_duration = request_end_time - request_start_time
151
- if self.first_api_response_time is None:
152
- self.first_api_response_time = time.time() - self.start_time
153
-
154
- self.request_logs.append({
155
- "id": request_id,
156
- "text": sentence,
157
- "start_time": request_start_time - self.start_time,
158
- "end_time": request_end_time - self.start_time,
159
- "duration": request_duration,
160
- "char_count": len(sentence),
161
- "retries": retries
162
- })
107
+ if not self.is_connected:
108
+ raise Exception("Failed to connect to WebSocket")
109
+
110
+ def synthesize(self, text: str) -> Generator[bytes, None, None]:
111
+ self._reset_state()
112
+ self._connect()
113
+
114
+ payload = self._create_payload(text)
115
+ self.ws.send(json.dumps(payload))
116
+
117
+ while True:
118
+ if not self.error_queue.empty():
119
+ raise self.error_queue.get()
163
120
 
164
- return audio_content
165
- except APIError as e:
166
- if retries < self.max_retries:
167
- if self.verbose:
168
- print(f"Retry {retries + 1}/{self.max_retries} for request: '{sentence[:30]}...'")
169
- return await self._synthesize_async(sentence, retries + 1)
170
- else:
171
- if self.verbose:
172
- print(f"Synthesis failed for sentence: {sentence} - Error: {e}. Retries Exhausted, for more information, visit https://waves.smallest.ai/")
173
- return None
174
-
175
-
176
- async def _run_synthesis(self) -> AsyncGenerator[bytes, None]:
177
- """
178
- Continuously synthesizes sentences from the queue, yielding audio content.
179
- If no sentences are in the queue, it waits until new data is available or streaming is complete.
180
- """
181
- while not self.stop_flag or not self.queue.empty():
182
121
  try:
183
- sentence = self.queue.get_nowait()
184
-
185
- if isinstance(self.tts_instance, AsyncWavesClient):
186
- audio_content = await self._synthesize_async(sentence)
187
- else:
188
- loop = asyncio.get_running_loop()
189
- audio_content = await loop.run_in_executor(None, self._synthesize_sync, sentence)
190
-
191
- if audio_content:
192
- yield audio_content
193
-
194
- except Empty:
195
- # Quick check if we should exit
196
- if self.stop_flag and self.queue.empty():
122
+ chunk = self.audio_queue.get(timeout=1.0)
123
+ if chunk is None:
197
124
  break
198
-
199
- # Short sleep to avoid busy-waiting
200
- await asyncio.sleep(0.01) # Much shorter sleep time (10ms)
201
-
202
-
203
- def _print_verbose_summary(self) -> None:
204
- """Print a summary of all metrics if verbose mode is enabled."""
205
- if not self.verbose:
206
- return
207
-
208
- total_duration = self.end_time - self.start_time
209
-
210
- print("\n" + "="*100)
211
- print(f"TEXT-TO-AUDIO STREAM METRICS")
212
- print("="*100)
125
+ yield chunk
126
+ except queue.Empty:
127
+ if self.is_complete:
128
+ break
129
+ continue
130
+
131
+ self.ws.close()
132
+
133
+ def synthesize_streaming(self, text_stream: Generator[str, None, None],
134
+ continue_stream: bool = True,
135
+ auto_flush: bool = True) -> Generator[bytes, None, None]:
136
+ self._reset_state()
137
+ self._connect()
213
138
 
214
- print(f"\nOVERALL STATISTICS:")
215
- print(f" Total requests made: {self.request_count}")
216
- print(f" Time to first API response: {self.first_api_response_time:.3f}s")
217
- print(f" Total processing time: {total_duration:.3f}s")
139
+ def send_text():
140
+ try:
141
+ for text_chunk in text_stream:
142
+ if text_chunk.strip():
143
+ payload = self._create_payload(text_chunk, continue_stream=continue_stream)
144
+ self.ws.send(json.dumps(payload))
145
+
146
+ if auto_flush:
147
+ flush_payload = self._create_payload("", flush=True)
148
+ self.ws.send(json.dumps(flush_payload))
149
+ except Exception as e:
150
+ self.error_queue.put(e)
218
151
 
219
- # Print table header
220
- print("\nREQUEST DETAILS:")
221
- header = f"{'#':4} {'Start (s)':10} {'End (s)':10} {'Duration (s)':12} {'Characters':15} {'Text'}"
222
- print("\n" + header)
223
- print("-" * 100)
152
+ sender_thread = threading.Thread(target=send_text)
153
+ sender_thread.daemon = True
154
+ sender_thread.start()
224
155
 
225
- # Print table rows
226
- for log in self.request_logs:
227
- row = (
228
- f"{log['id']:4} "
229
- f"{log['start_time']:10.3f} "
230
- f"{log['end_time']:10.3f} "
231
- f"{log['duration']:12.3f} "
232
- f"{log['char_count']:15} "
233
- f"{log['text'][:50]}{'...' if len(log['text']) > 50 else ''}"
234
- )
235
- print(row)
236
-
237
- # Print retry information if any
238
- if log['retries'] > 0:
239
- print(f"{'':4} {'':10} {'':10} {'':12} {'':15} Retries: {log['retries']}")
156
+ while True:
157
+ if not self.error_queue.empty():
158
+ raise self.error_queue.get()
240
159
 
241
- print("\n" + "="*100)
242
-
243
-
244
- async def process(self, llm_output: AsyncGenerator[str, None]) -> AsyncGenerator[bytes, None]:
245
- """
246
- Convert streaming text into audio in real-time.
247
-
248
- Handles the entire pipeline from receiving text to producing audio,
249
- yielding audio chunks as soon as they're ready.
250
-
251
- Args:
252
- llm_output: An async generator that yields text chunks.
253
-
254
- Yields:
255
- Raw audio data chunks (without WAV headers) that can be:
256
- - Played directly through an audio device
257
- - Saved to a file
258
- - Streamed over a network
259
- - Further processed as needed
260
- """
261
- self.start_time = time.time()
262
-
263
- llm_thread = Thread(target=asyncio.run, args=(self._stream_llm_output(llm_output),))
264
- llm_thread.start()
265
-
266
- async for audio_content in self._run_synthesis():
267
- yield audio_content
268
-
269
- llm_thread.join()
160
+ try:
161
+ chunk = self.audio_queue.get(timeout=1.0)
162
+ if chunk is None:
163
+ break
164
+ yield chunk
165
+ except queue.Empty:
166
+ if self.is_complete:
167
+ break
168
+ continue
169
+
170
+ self.ws.close()
171
+
172
+ def send_text_chunk(self, text: str, continue_stream: bool = True, flush: bool = False):
173
+ if not self.is_connected:
174
+ raise Exception("WebSocket not connected")
175
+ payload = self._create_payload(text, continue_stream=continue_stream, flush=flush)
176
+ self.ws.send(json.dumps(payload))
177
+
178
+ def flush_buffer(self):
179
+ if not self.is_connected:
180
+ raise Exception("WebSocket not connected")
181
+ payload = self._create_payload("", flush=True)
182
+ self.ws.send(json.dumps(payload))
183
+
184
+ def start_streaming_session(self) -> Generator[bytes, None, None]:
185
+ self._reset_state()
186
+ self._connect()
270
187
 
271
- self.end_time = time.time()
272
- self._print_verbose_summary()
188
+ while True:
189
+ if not self.error_queue.empty():
190
+ raise self.error_queue.get()
191
+
192
+ try:
193
+ chunk = self.audio_queue.get(timeout=0.1)
194
+ if chunk is None:
195
+ break
196
+ yield chunk
197
+ except queue.Empty:
198
+ if self.is_complete:
199
+ break
200
+ continue
201
+
202
+ def _reset_state(self):
203
+ self.audio_queue = queue.Queue()
204
+ self.error_queue = queue.Queue()
205
+ self.is_complete = False
206
+ self.is_connected = False
207
+ self.request_id = None
smallestai/waves/utils.py CHANGED
@@ -1,8 +1,5 @@
1
- import re
2
- import io
3
1
  from typing import List
4
2
  from typing import Optional
5
- from pydub import AudioSegment
6
3
  from dataclasses import dataclass
7
4
 
8
5
  from smallestai.waves.exceptions import ValidationError
@@ -10,7 +7,7 @@ from smallestai.waves.models import TTSModels, TTSLanguages_lightning, TTSLangua
10
7
 
11
8
 
12
9
  API_BASE_URL = "https://waves-api.smallest.ai/api/v1"
13
- SENTENCE_END_REGEX = re.compile(r'.*[-.—!?,;:…।|]$')
10
+ WEBSOCKET_URL = "wss://waves-api.smallest.ai/api/v1/lightning-v2/get_speech/stream"
14
11
  SAMPLE_WIDTH = 2
15
12
  CHANNELS = 1
16
13
  ALLOWED_AUDIO_EXTENSIONS = ['.mp3', '.wav']
@@ -22,11 +19,12 @@ class TTSOptions:
22
19
  sample_rate: int
23
20
  voice_id: str
24
21
  api_key: str
25
- add_wav_header: bool
26
22
  speed: float
27
23
  consistency: float
28
24
  similarity: float
29
25
  enhancement: int
26
+ language: str
27
+ output_format: str
30
28
 
31
29
 
32
30
  def validate_input(text: str, model: str, sample_rate: int, speed: float, consistency: Optional[float] = None, similarity: Optional[float] = None, enhancement: Optional[int] = None):
@@ -46,50 +44,6 @@ def validate_input(text: str, model: str, sample_rate: int, speed: float, consis
46
44
  raise ValidationError(f"Invalid enhancement: {enhancement}. Must be between 0 and 2.")
47
45
 
48
46
 
49
- def add_wav_header(frame_input: bytes, sample_rate: int = 24000, sample_width: int = 2, channels: int = 1) -> bytes:
50
- audio = AudioSegment(data=frame_input, sample_width=sample_width, frame_rate=sample_rate, channels=channels)
51
- wav_buf = io.BytesIO()
52
- audio.export(wav_buf, format="wav")
53
- wav_buf.seek(0)
54
- return wav_buf.read()
55
-
56
-
57
- def preprocess_text(text: str) -> str:
58
- text = text.replace("\n", " ").replace("\t", " ")
59
- text = re.sub(r'\s+', ' ', text)
60
- return text.strip()
61
-
62
-
63
- def chunk_text(text: str, chunk_size: int = 250) -> List[str]:
64
- chunks = []
65
- while text:
66
- if len(text) <= chunk_size:
67
- chunks.append(text.strip())
68
- break
69
-
70
- chunk_text = text[:chunk_size]
71
- last_break_index = -1
72
-
73
- # Find last sentence boundary using regex
74
- for i in range(len(chunk_text) - 1, -1, -1):
75
- if SENTENCE_END_REGEX.match(chunk_text[:i + 1]):
76
- last_break_index = i
77
- break
78
-
79
- if last_break_index == -1:
80
- # Fallback to space if no sentence boundary found
81
- last_space = chunk_text.rfind(' ')
82
- if last_space != -1:
83
- last_break_index = last_space
84
- else:
85
- last_break_index = chunk_size - 1
86
-
87
- chunks.append(text[:last_break_index + 1].strip())
88
- text = text[last_break_index + 1:].strip()
89
-
90
- return chunks
91
-
92
-
93
47
  def get_smallest_languages(model: str = 'lightning') -> List[str]:
94
48
  if model == 'lightning':
95
49
  return TTSLanguages_lightning