ojin-client 0.1.7.dev10__tar.gz → 0.1.7.dev12__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ojin-client
3
- Version: 0.1.7.dev10
3
+ Version: 0.1.7.dev12
4
4
  Summary: Ojin platform services
5
5
  Author: Journee
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- """WebSocket client for OJIN Persona service with optimized cancellation handling."""
1
+ """WebSocket client for OJIN Persona service with send buffer management."""
2
2
 
3
3
  import asyncio
4
4
  import contextlib
@@ -91,8 +91,10 @@ class OjinPersonaClient(IOjinPersonaClient):
91
91
  self._split_audio_task: Optional[asyncio.Task] = None
92
92
  self._audio_queue: asyncio.Queue[OjinPersonaInteractionInputMessage] = asyncio.Queue()
93
93
 
94
- # Add cancellation event for immediate stopping
94
+ # Add cancellation event and send queue for managing buffer backpressure
95
95
  self._cancel_event = asyncio.Event()
96
+ self._send_queue: asyncio.Queue = asyncio.Queue(maxsize=100) # Limit queue size
97
+ self._send_task: Optional[asyncio.Task] = None
96
98
 
97
99
  async def connect(self) -> None:
98
100
  """Establish WebSocket connection and authenticate with the service."""
@@ -109,12 +111,23 @@ class OjinPersonaClient(IOjinPersonaClient):
109
111
 
110
112
  # Add query parameters for API key and config ID
111
113
  url = f"{self.ws_url}?config_id={self.config_id}"
114
+ # Configure WebSocket with smaller buffers to reduce latency
112
115
  self._ws = await websockets.connect(
113
- url, additional_headers=headers, ping_interval=30, ping_timeout=10
116
+ url,
117
+ additional_headers=headers,
118
+ ping_interval=30,
119
+ ping_timeout=10,
120
+ # Reduce write buffer size to minimize send blocking
121
+ write_limit=16384, # 16KB instead of default 64KB
122
+ # Reduce max message size to prevent large messages from blocking
123
+ max_size=1024*1024, # 1MB max message size
124
+ # Reduce queue size to minimize buffering delay
125
+ max_queue=8 # Smaller queue than default 32
114
126
  )
115
127
  self._running = True
116
128
  self._receive_task = asyncio.create_task(self._receive_messages())
117
129
  self._split_audio_task = asyncio.create_task(self._split_audio())
130
+ self._send_task = asyncio.create_task(self._send_worker())
118
131
  logger.info("Successfully connected to OJIN Persona service")
119
132
  return
120
133
  except WebSocketException as e:
@@ -141,6 +154,13 @@ class OjinPersonaClient(IOjinPersonaClient):
141
154
  self._active_interaction_id = None
142
155
  self._cancel_event.set() # Signal cancellation to all tasks
143
156
 
157
+ # Stop send worker first to prevent new messages
158
+ if self._send_task:
159
+ self._send_task.cancel()
160
+ with contextlib.suppress(asyncio.CancelledError):
161
+ await self._send_task
162
+ self._send_task = None
163
+
144
164
  if self._ws:
145
165
  try:
146
166
  await self._ws.close()
@@ -162,6 +182,56 @@ class OjinPersonaClient(IOjinPersonaClient):
162
182
 
163
183
  logger.info("Disconnected from OJIN Persona service")
164
184
 
185
+ async def _send_worker(self) -> None:
186
+ """Dedicated worker to send messages without blocking the main thread."""
187
+ while self._running:
188
+ try:
189
+ # Wait for either a message to send or cancellation
190
+ send_task = asyncio.create_task(self._send_queue.get())
191
+ cancel_task = asyncio.create_task(self._cancel_event.wait())
192
+
193
+ done, pending = await asyncio.wait(
194
+ [send_task, cancel_task],
195
+ return_when=asyncio.FIRST_COMPLETED,
196
+ timeout=0.1
197
+ )
198
+
199
+ # Cancel pending tasks
200
+ for task in pending:
201
+ task.cancel()
202
+ with contextlib.suppress(asyncio.CancelledError):
203
+ await task
204
+
205
+ if self._cancelled or self._cancel_event.is_set():
206
+ # Clear send queue during cancellation
207
+ while not self._send_queue.empty():
208
+ try:
209
+ self._send_queue.get_nowait()
210
+ except asyncio.QueueEmpty:
211
+ break
212
+ continue
213
+
214
+ if done and send_task in done:
215
+ message_data = send_task.result()
216
+
217
+ if self._ws and not self._ws.closed:
218
+ try:
219
+ # Use asyncio.wait_for to prevent indefinite blocking
220
+ await asyncio.wait_for(
221
+ self._ws.send(message_data),
222
+ timeout=5.0 # 5 second timeout for sends
223
+ )
224
+ except asyncio.TimeoutError:
225
+ logger.error("Send timeout - WebSocket send buffer may be full")
226
+ # Don't break the connection, just log and continue
227
+ except Exception as e:
228
+ logger.error(f"Error sending message: {e}")
229
+ break
230
+
231
+ except Exception as e:
232
+ logger.error(f"Error in send worker: {e}")
233
+ break
234
+
165
235
  async def _receive_messages(self) -> None:
166
236
  """Continuously receive and process incoming messages."""
167
237
  if not self._ws:
@@ -292,21 +362,29 @@ class OjinPersonaClient(IOjinPersonaClient):
292
362
  self._cancelled = True
293
363
  self._cancel_event.set()
294
364
 
295
- # Send cancellation message with high priority
365
+ # Clear all queues immediately
366
+ self._clear_all_queues()
367
+
368
+ # Send cancellation message directly, bypassing the send queue
296
369
  cancel_input = CancelInteractionMessage(
297
370
  payload=message.to_proxy_message()
298
371
  )
299
372
 
300
- # Send immediately without waiting
373
+ # Send immediately with timeout to prevent blocking
301
374
  try:
302
- await self._ws.send(cancel_input.model_dump_json())
303
- logger.info(f"Cancellation message sent immediately for {message.interaction_id}")
375
+ if self._ws and not self._ws.closed:
376
+ await asyncio.wait_for(
377
+ self._ws.send(cancel_input.model_dump_json()),
378
+ timeout=1.0 # Short timeout for immediate sending
379
+ )
380
+ logger.warning(f"Cancellation message sent immediately for {message.interaction_id}")
381
+ except asyncio.TimeoutError:
382
+ logger.warning("Cancellation send timeout - forcing connection close")
383
+ await self.close()
384
+ return
304
385
  except Exception as e:
305
386
  logger.error(f"Failed to send cancellation message: {e}")
306
387
 
307
- # Clear queues quickly without blocking
308
- self._clear_queues_non_blocking()
309
-
310
388
  # Reset cancellation state
311
389
  self._cancelled = False
312
390
  self._cancel_event.clear()
@@ -321,7 +399,7 @@ class OjinPersonaClient(IOjinPersonaClient):
321
399
  interaction_id=interaction_id
322
400
  )
323
401
  # Clear queues non-blocking
324
- self._clear_queues_non_blocking()
402
+ self._clear_message_queue()
325
403
  self._message_queue.put_nowait(interaction_response)
326
404
  return
327
405
 
@@ -334,7 +412,11 @@ class OjinPersonaClient(IOjinPersonaClient):
334
412
  if not message.audio_int16_bytes:
335
413
  raise ValueError("Audio cannot be empty")
336
414
 
337
- await self._audio_queue.put(message)
415
+ # Use put_nowait to prevent blocking - drop messages if queue is full
416
+ try:
417
+ self._audio_queue.put_nowait(message)
418
+ except asyncio.QueueFull:
419
+ logger.warning("Audio queue full - dropping audio message")
338
420
  return
339
421
 
340
422
  logger.error("The message %s is Unknown", message)
@@ -350,22 +432,36 @@ class OjinPersonaClient(IOjinPersonaClient):
350
432
  )
351
433
  raise Exception(error)
352
434
 
353
- def _clear_queues_non_blocking(self) -> None:
435
+ def _clear_all_queues(self) -> None:
354
436
  """Clear all queues without blocking."""
355
- # Clear message queue
437
+ self._clear_message_queue()
438
+ self._clear_audio_queue()
439
+ self._clear_send_queue()
440
+
441
+ def _clear_message_queue(self) -> None:
442
+ """Clear message queue without blocking."""
356
443
  while True:
357
444
  try:
358
445
  self._message_queue.get_nowait()
359
446
  except asyncio.QueueEmpty:
360
447
  break
361
-
362
- # Clear audio queue
448
+
449
+ def _clear_audio_queue(self) -> None:
450
+ """Clear audio queue without blocking."""
363
451
  while True:
364
452
  try:
365
453
  self._audio_queue.get_nowait()
366
454
  except asyncio.QueueEmpty:
367
455
  break
368
456
 
457
+ def _clear_send_queue(self) -> None:
458
+ """Clear send queue without blocking."""
459
+ while True:
460
+ try:
461
+ self._send_queue.get_nowait()
462
+ except asyncio.QueueEmpty:
463
+ break
464
+
369
465
  async def _split_audio(self) -> None:
370
466
  """Split audio into chunks and send them, with cancellation support."""
371
467
  while self._running:
@@ -446,10 +542,12 @@ class OjinPersonaClient(IOjinPersonaClient):
446
542
  proxy_message = InteractionInputMessage(payload=interaction_input)
447
543
 
448
544
  try:
449
- await self._ws.send(proxy_message.to_bytes())
450
- except Exception as e:
451
- logger.error(f"Failed to send audio chunk: {e}")
452
- break
545
+ # Use put_nowait to prevent blocking on full send queue
546
+ self._send_queue.put_nowait(proxy_message.to_bytes())
547
+ except asyncio.QueueFull:
548
+ logger.warning("Send queue full - dropping audio chunk")
549
+ # Continue with next chunk instead of breaking
550
+ continue
453
551
 
454
552
  async def receive_message(self) -> BaseModel | None:
455
553
  """Receive the next message from the OJIN Persona service.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ojin-client
3
- Version: 0.1.7.dev10
3
+ Version: 0.1.7.dev12
4
4
  Summary: Ojin platform services
5
5
  Author: Journee
6
6
  License: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ojin-client"
3
- version = "0.1.7dev10"
3
+ version = "0.1.7dev12"
4
4
  description = "Ojin platform services"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"