simo 2.11.4__py3-none-any.whl → 3.0.4__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 simo might be problematic. Click here for more details.

Files changed (91) hide show
  1. simo/__pycache__/settings.cpython-312.pyc +0 -0
  2. simo/asgi.py +25 -6
  3. simo/automation/__pycache__/controllers.cpython-312.pyc +0 -0
  4. simo/automation/controllers.py +18 -2
  5. simo/automation/forms.py +15 -24
  6. simo/automation/gateways.py +32 -16
  7. simo/core/__pycache__/admin.cpython-312.pyc +0 -0
  8. simo/core/__pycache__/base_types.cpython-312.pyc +0 -0
  9. simo/core/__pycache__/controllers.cpython-312.pyc +0 -0
  10. simo/core/__pycache__/forms.cpython-312.pyc +0 -0
  11. simo/core/__pycache__/models.cpython-312.pyc +0 -0
  12. simo/core/__pycache__/serializers.cpython-312.pyc +0 -0
  13. simo/core/__pycache__/signal_receivers.cpython-312.pyc +0 -0
  14. simo/core/__pycache__/tasks.cpython-312.pyc +0 -0
  15. simo/core/admin.py +5 -4
  16. simo/core/base_types.py +191 -18
  17. simo/core/controllers.py +259 -26
  18. simo/core/forms.py +10 -2
  19. simo/core/management/_hub_template/hub/nginx.conf +23 -50
  20. simo/core/management/_hub_template/hub/supervisor.conf +15 -0
  21. simo/core/mcp.py +154 -0
  22. simo/core/migrations/0051_instance_ai_memory.py +18 -0
  23. simo/core/migrations/__pycache__/0051_instance_ai_memory.cpython-312.pyc +0 -0
  24. simo/core/models.py +3 -0
  25. simo/core/serializers.py +120 -0
  26. simo/core/signal_receivers.py +1 -1
  27. simo/core/tasks.py +1 -3
  28. simo/core/utils/__pycache__/type_constants.cpython-312.pyc +0 -0
  29. simo/core/utils/type_constants.py +78 -17
  30. simo/fleet/__pycache__/admin.cpython-312.pyc +0 -0
  31. simo/fleet/__pycache__/api.cpython-312.pyc +0 -0
  32. simo/fleet/__pycache__/base_types.cpython-312.pyc +0 -0
  33. simo/fleet/__pycache__/controllers.cpython-312.pyc +0 -0
  34. simo/fleet/__pycache__/forms.cpython-312.pyc +0 -0
  35. simo/fleet/__pycache__/gateways.cpython-312.pyc +0 -0
  36. simo/fleet/__pycache__/models.cpython-312.pyc +0 -0
  37. simo/fleet/__pycache__/serializers.cpython-312.pyc +0 -0
  38. simo/fleet/admin.py +5 -1
  39. simo/fleet/api.py +2 -27
  40. simo/fleet/base_types.py +35 -4
  41. simo/fleet/controllers.py +162 -156
  42. simo/fleet/forms.py +58 -88
  43. simo/fleet/gateways.py +8 -15
  44. simo/fleet/migrations/0055_colonel_is_vo_active_colonel_last_wake_and_more.py +28 -0
  45. simo/fleet/migrations/0056_delete_customdalidevice.py +16 -0
  46. simo/fleet/migrations/__pycache__/0055_colonel_is_vo_active_colonel_last_wake_and_more.cpython-312.pyc +0 -0
  47. simo/fleet/migrations/__pycache__/0056_delete_customdalidevice.cpython-312.pyc +0 -0
  48. simo/fleet/models.py +13 -72
  49. simo/fleet/serializers.py +1 -48
  50. simo/fleet/socket_consumers.py +100 -39
  51. simo/fleet/tasks.py +2 -22
  52. simo/fleet/voice_assistant.py +903 -0
  53. simo/generic/__pycache__/base_types.cpython-312.pyc +0 -0
  54. simo/generic/__pycache__/controllers.cpython-312.pyc +0 -0
  55. simo/generic/__pycache__/gateways.cpython-312.pyc +0 -0
  56. simo/generic/base_types.py +70 -10
  57. simo/generic/controllers.py +104 -17
  58. simo/generic/gateways.py +10 -10
  59. simo/mcp_server/__init__.py +0 -0
  60. simo/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
  61. simo/mcp_server/__pycache__/admin.cpython-312.pyc +0 -0
  62. simo/mcp_server/__pycache__/models.cpython-312.pyc +0 -0
  63. simo/mcp_server/admin.py +18 -0
  64. simo/mcp_server/app.py +4 -0
  65. simo/mcp_server/auth.py +34 -0
  66. simo/mcp_server/dummy.py +22 -0
  67. simo/mcp_server/migrations/0001_initial.py +30 -0
  68. simo/mcp_server/migrations/0002_alter_instanceaccesstoken_date_expired.py +18 -0
  69. simo/mcp_server/migrations/0003_instanceaccesstoken_issuer.py +18 -0
  70. simo/mcp_server/migrations/__init__.py +0 -0
  71. simo/mcp_server/migrations/__pycache__/0001_initial.cpython-312.pyc +0 -0
  72. simo/mcp_server/migrations/__pycache__/0002_alter_instanceaccesstoken_date_expired.cpython-312.pyc +0 -0
  73. simo/mcp_server/migrations/__pycache__/0003_instanceaccesstoken_issuer.cpython-312.pyc +0 -0
  74. simo/mcp_server/migrations/__pycache__/__init__.cpython-312.pyc +0 -0
  75. simo/mcp_server/models.py +27 -0
  76. simo/mcp_server/server.py +60 -0
  77. simo/mcp_server/tasks.py +19 -0
  78. simo/multimedia/__pycache__/base_types.cpython-312.pyc +0 -0
  79. simo/multimedia/__pycache__/controllers.cpython-312.pyc +0 -0
  80. simo/multimedia/base_types.py +29 -4
  81. simo/multimedia/controllers.py +66 -19
  82. simo/settings.py +1 -0
  83. simo/users/__pycache__/utils.cpython-312.pyc +0 -0
  84. simo/users/utils.py +10 -0
  85. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/METADATA +11 -4
  86. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/RECORD +90 -64
  87. simo/fleet/custom_dali_operations.py +0 -287
  88. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/WHEEL +0 -0
  89. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/entry_points.txt +0 -0
  90. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/licenses/LICENSE.md +0 -0
  91. {simo-2.11.4.dist-info → simo-3.0.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,903 @@
1
+ import asyncio
2
+ import io
3
+ import json
4
+ import sys
5
+ import time
6
+ import traceback
7
+ import inspect
8
+ from datetime import timedelta
9
+
10
+ import websockets
11
+ import lameenc
12
+ from pydub import AudioSegment
13
+ from django.db import transaction
14
+ from django.utils import timezone
15
+ from django.conf import settings
16
+ from asgiref.sync import sync_to_async
17
+
18
+ from simo.conf import dynamic_settings
19
+
20
+
21
+ class VoiceAssistantSession:
22
+ """Manages a single Sentinel voice session for a connected Colonel.
23
+
24
+ - Buffers PCM from device, finalizes utterance on VAD-like quiet.
25
+ - Encodes PCM→MP3, calls Website WS, receives MP3 reply.
26
+ - Decodes MP3→PCM and streams to device paced.
27
+ - Manages `is_vo_active` lifecycle and Website start/finish HTTP hooks.
28
+ - Cloud traffic is gated until arbitration grants winner status.
29
+ """
30
+
31
+ INACTIVITY_MS = 800
32
+ MAX_UTTERANCE_SEC = 20
33
+ PLAY_CHUNK_BYTES = 1024
34
+ PLAY_CHUNK_INTERVAL = 0.032
35
+ FOLLOWUP_SEC = 15
36
+ CLOUD_RESPONSE_TIMEOUT_SEC = 60
37
+
38
+ def __init__(self, consumer):
39
+ self.c = consumer
40
+ self.active = False
41
+ self.awaiting_response = False
42
+ self.playing = False
43
+ self._end_after_playback = False
44
+ self.capture_buf = bytearray()
45
+ self.last_chunk_ts = 0.0
46
+ self.last_rx_audio_ts = 0.0
47
+ self.last_tx_audio_ts = 0.0
48
+ self.started_ts = None
49
+ self.mcp_token = None
50
+ self._finalizer_task = None
51
+ self._cloud_task = None
52
+ self._play_task = None
53
+ self._followup_task = None
54
+ self.voice = 'male'
55
+ self.zone = None
56
+ self._cloud_gate = asyncio.Event()
57
+ self._start_session_notified = False
58
+ self._start_session_inflight = False
59
+ self._prewarm_requested = False
60
+ self._idle_task = asyncio.create_task(self._idle_watchdog())
61
+ self._utterance_task = asyncio.create_task(self._utterance_watchdog())
62
+
63
+ async def start_if_needed(self):
64
+ if self.active:
65
+ return
66
+ self.active = True
67
+ self.started_ts = time.time()
68
+ # Ensure a fresh session starts gated until arbitration grants winner
69
+ try:
70
+ self._cloud_gate.clear()
71
+ except Exception:
72
+ pass
73
+ # is_vo_active will be set by arbitration via open_as_winner
74
+
75
+ async def on_audio_chunk(self, payload: bytes):
76
+ if self.playing or self.awaiting_response:
77
+ return
78
+ await self.start_if_needed()
79
+ if not getattr(self, '_rx_started', False):
80
+ self._rx_started = True
81
+ self._rx_start_ts = time.time()
82
+ print("VA RX START (device→hub)")
83
+ self.capture_buf.extend(payload)
84
+ self.last_chunk_ts = time.time()
85
+ self.last_rx_audio_ts = self.last_chunk_ts
86
+ if len(self.capture_buf) > 2 * 16000 * self.MAX_UTTERANCE_SEC:
87
+ await self._finalize_utterance()
88
+ return
89
+ if not self._finalizer_task or self._finalizer_task.done():
90
+ self._finalizer_task = asyncio.create_task(self._finalizer_loop())
91
+
92
+ async def _finalizer_loop(self):
93
+ try:
94
+ while True:
95
+ if not self.active:
96
+ return
97
+ if self.awaiting_response or self.playing:
98
+ return
99
+ if self.last_chunk_ts and (time.time() - self.last_chunk_ts) * 1000 >= self.INACTIVITY_MS:
100
+ print("VA FINALIZE UTTERANCE (quiet)")
101
+ await self._finalize_utterance()
102
+ return
103
+ await asyncio.sleep(0.05)
104
+ except asyncio.CancelledError:
105
+ return
106
+
107
+ async def _utterance_watchdog(self):
108
+ while True:
109
+ try:
110
+ await asyncio.sleep(0.1)
111
+ if not self.active or self.awaiting_response or self.playing:
112
+ continue
113
+ if self.capture_buf and self.last_chunk_ts and (time.time() - self.last_chunk_ts) * 1000 >= self.INACTIVITY_MS:
114
+ print("VA FINALIZE (watchdog)")
115
+ await self._finalize_utterance()
116
+ except asyncio.CancelledError:
117
+ return
118
+ except Exception:
119
+ pass
120
+
121
+ async def _finalize_utterance(self):
122
+ if not self.capture_buf:
123
+ return
124
+ pcm = bytes(self.capture_buf)
125
+ self.capture_buf.clear()
126
+ self.last_chunk_ts = 0
127
+ try:
128
+ dur = time.time() - (self._rx_start_ts or time.time())
129
+ print(f"VA RX END (device→hub) bytes={len(pcm)} dur={dur:.2f}s")
130
+ samples = len(pcm) // 2
131
+ exp = samples / 16000.0
132
+ if exp:
133
+ print(f"VA CAPTURE STATS: samples={samples} sec={exp:.2f} wall={dur:.2f} ratio={dur/exp:.2f}")
134
+ except Exception:
135
+ pass
136
+ finally:
137
+ self._rx_started = False
138
+ if self._cloud_task and not self._cloud_task.done():
139
+ return
140
+ self._cloud_task = asyncio.create_task(self._cloud_roundtrip_and_play(pcm))
141
+
142
+ async def _cloud_roundtrip_and_play(self, pcm_bytes: bytes):
143
+ try:
144
+ await asyncio.wait_for(self._cloud_gate.wait(), timeout=30)
145
+ except asyncio.TimeoutError:
146
+ return
147
+ self.awaiting_response = True
148
+ try:
149
+ # Ensure we have an MCP token before contacting Website
150
+ if self.mcp_token is None:
151
+ try:
152
+ await self.ensure_mcp_token()
153
+ except Exception:
154
+ pass
155
+ # Hard guard: abort if token still missing
156
+ if self.mcp_token is None or not getattr(self.mcp_token, 'token', None):
157
+ raise RuntimeError("Missing MCP token for Website WS call")
158
+
159
+ if (not self._start_session_notified) and (not self._start_session_inflight):
160
+ try:
161
+ await self._start_cloud_session()
162
+ except Exception:
163
+ pass
164
+ else:
165
+ self._start_session_notified = True
166
+ mp3_bytes = await self._encode_mp3(pcm_bytes)
167
+ if not mp3_bytes:
168
+ return
169
+ print(f"VA TX START (hub→website) mp3={len(mp3_bytes)}B")
170
+ ws_url = "wss://simo.io/ws/voice-assistant/"
171
+ hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
172
+ hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
173
+ headers = {
174
+ "hub-uid": hub_uid,
175
+ "hub-secret": hub_secret,
176
+ "instance-uid": self.c.instance.uid,
177
+ "mcp-token": getattr(self.mcp_token, 'token', None),
178
+ "voice": self.voice,
179
+ "zone": self.zone
180
+ }
181
+ if not websockets:
182
+ raise RuntimeError("websockets library not available")
183
+ print(f"VA WS CONNECT {ws_url}")
184
+
185
+ kwargs = {'max_size': 10 * 1024 * 1024}
186
+ ws_params = inspect.signature(websockets.connect).parameters
187
+ if 'additional_headers' in ws_params:
188
+ kwargs['additional_headers'] = headers
189
+ else:
190
+ kwargs['extra_headers'] = headers
191
+ async with websockets.connect(ws_url, **kwargs) as ws:
192
+ print("VA WS OPEN")
193
+ await ws.send(mp3_bytes)
194
+ print("VA WS SENT (binary)")
195
+ deadline = time.time() + self.CLOUD_RESPONSE_TIMEOUT_SEC
196
+ mp3_reply = None
197
+ streaming = False
198
+ streaming_opus = False
199
+ sent_total = 0
200
+ stream_chunks = 0
201
+ stream_start_ts = None
202
+ opus_proc = None
203
+ pcm_forward_task = None
204
+ pcm_start_threshold = 8192 # ~256ms @ 16kHz s16le mono
205
+ pcm_buffer = bytearray()
206
+ ws_closed_ok = False
207
+ ws_closed_error = False
208
+ ws_closed_code = None
209
+ pcm_stats_sent = 0
210
+ async def _start_opus_decoder():
211
+ nonlocal opus_proc, pcm_forward_task, pcm_buffer, pcm_stats_sent
212
+ if opus_proc is not None:
213
+ return
214
+ try:
215
+ opus_proc = await asyncio.create_subprocess_exec(
216
+ 'ffmpeg', '-v', 'error', '-i', 'pipe:0',
217
+ '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1',
218
+ stdin=asyncio.subprocess.PIPE,
219
+ stdout=asyncio.subprocess.PIPE,
220
+ stderr=asyncio.subprocess.PIPE,
221
+ )
222
+ except Exception as e:
223
+ print('VA: failed to start ffmpeg for opus decode:', e, file=sys.stderr)
224
+ opus_proc = None
225
+ return
226
+
227
+ async def _pcm_forwarder():
228
+ nonlocal pcm_buffer, pcm_stats_sent, stream_start_ts, stream_chunks, sent_total
229
+ started = False
230
+ next_deadline = 0.0
231
+ try:
232
+ while True:
233
+ chunk = await opus_proc.stdout.read(4096)
234
+ if not chunk:
235
+ break
236
+ pcm_buffer.extend(chunk)
237
+ if not started and len(pcm_buffer) >= pcm_start_threshold:
238
+ started = True
239
+ if stream_start_ts is None:
240
+ stream_start_ts = time.time()
241
+ print('VA RX STREAM START (website→hub) opus->pcm')
242
+ next_deadline = time.time()
243
+ # forward in paced frames (~32ms for 1024B)
244
+ while started and len(pcm_buffer) >= self.PLAY_CHUNK_BYTES:
245
+ frame = bytes(pcm_buffer[:self.PLAY_CHUNK_BYTES])
246
+ del pcm_buffer[:self.PLAY_CHUNK_BYTES]
247
+ try:
248
+ # pacing
249
+ samples = len(frame) // 2
250
+ dt = samples / 16000.0
251
+ sleep_for = next_deadline - time.time()
252
+ if sleep_for > 0:
253
+ await asyncio.sleep(sleep_for)
254
+ await self.c.send(bytes_data=b"\x01" + frame)
255
+ self.last_tx_audio_ts = time.time()
256
+ sent_total += len(frame)
257
+ stream_chunks += 1
258
+ pcm_stats_sent += len(frame)
259
+ if not self.playing:
260
+ self.playing = True
261
+ next_deadline += dt
262
+ except Exception:
263
+ return
264
+ except asyncio.CancelledError:
265
+ return
266
+ except Exception as e:
267
+ print('VA: opus decode forward error:', e, file=sys.stderr)
268
+ finally:
269
+ # flush tail with pacing
270
+ if started and pcm_buffer:
271
+ try:
272
+ while pcm_buffer:
273
+ take = min(self.PLAY_CHUNK_BYTES, len(pcm_buffer))
274
+ frame = bytes(pcm_buffer[:take])
275
+ del pcm_buffer[:take]
276
+ samples = len(frame) // 2
277
+ dt = samples / 16000.0
278
+ sleep_for = next_deadline - time.time()
279
+ if sleep_for > 0:
280
+ await asyncio.sleep(sleep_for)
281
+ await self.c.send(bytes_data=b"\x01" + frame)
282
+ self.last_tx_audio_ts = time.time()
283
+ sent_total += len(frame)
284
+ stream_chunks += 1
285
+ next_deadline += dt
286
+ except Exception:
287
+ pass
288
+ pcm_buffer = bytearray()
289
+
290
+ pcm_forward_task = asyncio.create_task(_pcm_forwarder())
291
+ while True:
292
+ remaining = deadline - time.time()
293
+ if remaining <= 0:
294
+ try:
295
+ await ws.close()
296
+ except Exception:
297
+ pass
298
+ raise asyncio.TimeoutError("Cloud response timeout waiting for audio reply")
299
+ try:
300
+ msg = await asyncio.wait_for(ws.recv(), timeout=remaining)
301
+ except asyncio.TimeoutError:
302
+ try:
303
+ await ws.close()
304
+ except Exception:
305
+ pass
306
+ raise
307
+ except Exception as e:
308
+ # Connection closed or errored — inspect close code
309
+ try:
310
+ from websockets.exceptions import ConnectionClosed
311
+ except Exception:
312
+ ConnectionClosed = tuple()
313
+ if isinstance(e, ConnectionClosed):
314
+ try:
315
+ ws_closed_code = getattr(e, 'code', None)
316
+ except Exception:
317
+ ws_closed_code = None
318
+ if ws_closed_code == 1000 or self._end_after_playback:
319
+ ws_closed_ok = True
320
+ else:
321
+ ws_closed_error = True
322
+ break
323
+ # Any other exception (e.g., network reset) => treat as error end if streaming started
324
+ if streaming or streaming_opus:
325
+ ws_closed_error = True
326
+ break
327
+ # Otherwise, propagate to outer handler (will send error finish)
328
+ raise e
329
+ # Reset deadline on activity
330
+ deadline = time.time() + self.CLOUD_RESPONSE_TIMEOUT_SEC
331
+ if isinstance(msg, (bytes, bytearray)):
332
+ if streaming_opus:
333
+ # Feed opus bytes into decoder stdin
334
+ try:
335
+ if opus_proc is not None and opus_proc.stdin:
336
+ opus_proc.stdin.write(msg)
337
+ await opus_proc.stdin.drain()
338
+ except Exception as e:
339
+ print('VA: opus stdin write failed:', e, file=sys.stderr)
340
+ break
341
+ continue
342
+ if streaming:
343
+ if stream_start_ts is None:
344
+ stream_start_ts = time.time()
345
+ print("VA RX STREAM START (website→hub) pcm16le")
346
+ try:
347
+ await self.c.send(bytes_data=b"\x01" + bytes(msg))
348
+ self.last_tx_audio_ts = time.time()
349
+ sent_total += len(msg)
350
+ stream_chunks += 1
351
+ if not self.playing:
352
+ self.playing = True
353
+ except Exception:
354
+ break
355
+ continue
356
+ # Not in streaming mode: assume single MP3 blob
357
+ mp3_reply = bytes(msg)
358
+ print(f"VA RX START (website→hub) mp3={len(mp3_reply)}B")
359
+ break
360
+ else:
361
+ try:
362
+ data = json.loads(msg)
363
+ except Exception:
364
+ data = None
365
+ if isinstance(data, dict):
366
+ print(f"VA WS CTRL {data}")
367
+ # Streaming handshake
368
+ audio = data.get('audio') if isinstance(data.get('audio'), dict) else None
369
+ if audio and audio.get('format') == 'pcm16le':
370
+ if int(audio.get('sr', 0)) != 16000:
371
+ print("VA: unsupported stream rate, expecting 16k; ignoring stream")
372
+ else:
373
+ streaming = True
374
+ continue
375
+ if audio and audio.get('format') == 'opus':
376
+ # Start opus->pcm decoder
377
+ await _start_opus_decoder()
378
+ if opus_proc is not None:
379
+ streaming_opus = True
380
+ continue
381
+ if data.get('session') == 'finish':
382
+ self._end_after_playback = True
383
+ try:
384
+ await self.c.send_data(
385
+ {'command': 'va', 'session': 'finish',
386
+ 'status': data.get('status', 'success')}
387
+ )
388
+ except Exception:
389
+ pass
390
+ if 'reasoning' in data:
391
+ try:
392
+ await self.c.send_data({'command': 'va', 'reasoning': bool(data['reasoning'])})
393
+ except Exception:
394
+ pass
395
+
396
+ if mp3_reply:
397
+ pcm_out = await self._decode_mp3(mp3_reply)
398
+ if pcm_out:
399
+ await self._play_to_device(pcm_out)
400
+ if self._end_after_playback:
401
+ await self._end_session(cloud_also=False)
402
+ self._end_after_playback = False
403
+ elif self._end_after_playback:
404
+ await self._end_session(cloud_also=False)
405
+ self._end_after_playback = False
406
+ elif streaming:
407
+ # Streaming ended; finalize playback stats
408
+ try:
409
+ elapsed = time.time() - (stream_start_ts or time.time())
410
+ audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
411
+ print(f"VA RX STREAM END (website→hub) sent≈{sent_total}B chunks={stream_chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
412
+ except Exception:
413
+ pass
414
+ self.playing = False
415
+ if self._end_after_playback:
416
+ await self._end_session(cloud_also=False)
417
+ self._end_after_playback = False
418
+ elif streaming_opus:
419
+ # Close decoder stdin and let forwarder drain fully
420
+ try:
421
+ if opus_proc and opus_proc.stdin:
422
+ try:
423
+ opus_proc.stdin.close()
424
+ except Exception:
425
+ pass
426
+ except Exception:
427
+ pass
428
+ # Allow forwarder to finish without artificially short timeouts
429
+ if pcm_forward_task:
430
+ try:
431
+ await pcm_forward_task
432
+ except Exception:
433
+ try:
434
+ pcm_forward_task.cancel()
435
+ except Exception:
436
+ pass
437
+ # Ensure process exits cleanly
438
+ try:
439
+ if opus_proc:
440
+ try:
441
+ await opus_proc.wait()
442
+ except Exception:
443
+ opus_proc.kill()
444
+ except Exception:
445
+ pass
446
+ try:
447
+ elapsed = time.time() - (stream_start_ts or time.time())
448
+ audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
449
+ print(f"VA RX OPUS END sent≈{sent_total}B chunks={stream_chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
450
+ except Exception:
451
+ pass
452
+ self.playing = False
453
+ if self._end_after_playback:
454
+ await self._end_session(cloud_also=False)
455
+ self._end_after_playback = False
456
+ elif self._end_after_playback:
457
+ await self._end_session(cloud_also=False)
458
+ self._end_after_playback = False
459
+ elif ws_closed_error:
460
+ # Website closed with a non-1000 code: finish with error immediately
461
+ try:
462
+ await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'error'})
463
+ except Exception:
464
+ pass
465
+ await self._end_session(cloud_also=True)
466
+ elif ws_closed_ok:
467
+ # Normal close without explicit finish: keep session open for follow-up.
468
+ pass
469
+ except Exception as e:
470
+ print("VA WS ERROR:", e, file=sys.stderr)
471
+ print("VA: Cloud roundtrip failed\n", traceback.format_exc(), file=sys.stderr)
472
+ try:
473
+ await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'error'})
474
+ except Exception:
475
+ pass
476
+ await self._end_session(cloud_also=True)
477
+ finally:
478
+ self.awaiting_response = False
479
+ if self.active and not self.playing and not self._end_after_playback:
480
+ await self._start_followup_timer()
481
+
482
+ async def _encode_mp3(self, pcm_bytes: bytes):
483
+ if lameenc is None:
484
+ loop = asyncio.get_running_loop()
485
+ return await loop.run_in_executor(None, lambda: self._encode_mp3_pydub(pcm_bytes))
486
+ def _enc():
487
+ enc = lameenc.Encoder()
488
+ enc.set_bit_rate(48)
489
+ enc.set_in_sample_rate(16000)
490
+ enc.set_channels(1)
491
+ enc.set_quality(2)
492
+ return enc.encode(pcm_bytes) + enc.flush()
493
+ try:
494
+ loop = asyncio.get_running_loop()
495
+ return await loop.run_in_executor(None, _enc)
496
+ except Exception:
497
+ print("VA: lameenc failed, fallback to pydub", file=sys.stderr)
498
+ loop = asyncio.get_running_loop()
499
+ return await loop.run_in_executor(None, lambda: self._encode_mp3_pydub(pcm_bytes))
500
+
501
+ def _encode_mp3_pydub(self, pcm_bytes: bytes):
502
+ if AudioSegment is None:
503
+ return None
504
+ audio = AudioSegment(data=pcm_bytes, sample_width=2, frame_rate=16000, channels=1)
505
+ out = io.BytesIO()
506
+ audio.export(out, format='mp3', bitrate='48k')
507
+ return out.getvalue()
508
+
509
+ async def _decode_mp3(self, mp3_bytes: bytes):
510
+ if AudioSegment is None:
511
+ return None
512
+ def _dec():
513
+ audio = AudioSegment.from_file(io.BytesIO(mp3_bytes), format='mp3')
514
+ audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2)
515
+ return audio.raw_data
516
+ try:
517
+ loop = asyncio.get_running_loop()
518
+ return await loop.run_in_executor(None, _dec)
519
+ except Exception:
520
+ print("VA: MP3 decode failed\n", traceback.format_exc(), file=sys.stderr)
521
+ return None
522
+
523
+ async def _play_to_device(self, pcm_bytes: bytes):
524
+ self.playing = True
525
+ try:
526
+ print(f"VA TX START (hub→device) pcm={len(pcm_bytes)}B")
527
+ view = memoryview(pcm_bytes)
528
+ total = len(view)
529
+ pos = 0
530
+ sent_total = 0
531
+ next_deadline = time.time()
532
+ fudge = 0.0
533
+ pace_start = time.time()
534
+ chunks = 0
535
+ warmup = 1
536
+ while pos < total and self.c.connected:
537
+ chunk = view[pos:pos + self.PLAY_CHUNK_BYTES]
538
+ pos += len(chunk)
539
+ try:
540
+ await self.c.send(bytes_data=b"\x01" + bytes(chunk))
541
+ self.last_tx_audio_ts = time.time()
542
+ sent_total += len(chunk)
543
+ chunks += 1
544
+ except Exception:
545
+ break
546
+ if warmup > 0:
547
+ warmup -= 1
548
+ else:
549
+ samples = len(chunk) // 2
550
+ dt = samples / 16000.0
551
+ next_deadline += dt
552
+ drift = next_deadline - time.time()
553
+ sleep_for = drift + fudge
554
+ if sleep_for > 0:
555
+ await asyncio.sleep(sleep_for)
556
+ finally:
557
+ self.playing = False
558
+ try:
559
+ elapsed = time.time() - pace_start if 'pace_start' in locals() else 0.0
560
+ audio_sec = (sent_total // 2) / 16000.0 if sent_total else 0.0
561
+ print(f"VA TX END (hub→device) sent≈{sent_total}B chunks={chunks} elapsed={elapsed:.2f}s audio={audio_sec:.2f}s ratio={elapsed/audio_sec if audio_sec else 0:.2f}")
562
+ except Exception:
563
+ pass
564
+
565
+ async def _start_followup_timer(self):
566
+ if self._followup_task and not self._followup_task.done():
567
+ self._followup_task.cancel()
568
+ async def _timer():
569
+ try:
570
+ await asyncio.sleep(self.FOLLOWUP_SEC)
571
+ if self.active and not self.playing and not self.awaiting_response and not self.capture_buf:
572
+ await self._end_session(cloud_also=False)
573
+ except asyncio.CancelledError:
574
+ return
575
+ self._followup_task = asyncio.create_task(_timer())
576
+
577
+ async def _idle_watchdog(self):
578
+ IDLE_SEC = 120
579
+ while True:
580
+ try:
581
+ await asyncio.sleep(2)
582
+ if not self.active:
583
+ continue
584
+ last_audio = max(self.last_rx_audio_ts or 0, self.last_tx_audio_ts or 0)
585
+ if not last_audio:
586
+ continue
587
+ if (time.time() - last_audio) > IDLE_SEC:
588
+ print("VA idle timeout reached (120s), ending session")
589
+ await self._end_session(cloud_also=True)
590
+ except asyncio.CancelledError:
591
+ return
592
+ except Exception:
593
+ pass
594
+
595
+ async def _set_is_vo_active(self, flag: bool):
596
+ def _execute():
597
+ from simo.mcp_server.models import InstanceAccessToken
598
+ with transaction.atomic():
599
+ if flag:
600
+ self.mcp_token, _ = InstanceAccessToken.objects.get_or_create(
601
+ instance=self.c.colonel.instance, date_expired=None, issuer='sentinel'
602
+ )
603
+ else:
604
+ # Do NOT eagerly expire the token here; it may be in use
605
+ # by Website prewarm or by the chosen winner on this instance.
606
+ # Cleanup is handled by a scheduled task (1-day expiry).
607
+ self.mcp_token = None
608
+ self.c.colonel.is_vo_active = flag
609
+ self.c.colonel.save(update_fields=['is_vo_active'])
610
+ await sync_to_async(_execute, thread_sensitive=True)()
611
+
612
+ async def _finish_cloud_session(self):
613
+ try:
614
+ import requests
615
+ except Exception:
616
+ return
617
+ hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
618
+ hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
619
+ url = 'https://simo.io/ai/finish-session/'
620
+ payload = {
621
+ 'hub_uid': hub_uid,
622
+ 'hub_secret': hub_secret,
623
+ 'instance_uid': self.c.instance.uid,
624
+ }
625
+ def _post():
626
+ try:
627
+ return requests.post(url, json=payload, timeout=5)
628
+ except Exception:
629
+ return None
630
+ for delay in (0, 2, 5):
631
+ if delay:
632
+ await asyncio.sleep(delay)
633
+ loop = asyncio.get_running_loop()
634
+ resp = await loop.run_in_executor(None, _post)
635
+ if resp is not None and getattr(resp, 'status_code', None) in (200, 204):
636
+ return
637
+
638
+ async def _start_cloud_session(self):
639
+ try:
640
+ import requests
641
+ except Exception:
642
+ return
643
+ hub_uid = await sync_to_async(lambda: dynamic_settings['core__hub_uid'], thread_sensitive=True)()
644
+ hub_secret = await sync_to_async(lambda: dynamic_settings['core__hub_secret'], thread_sensitive=True)()
645
+ url = 'https://simo.io/ai/start-session/'
646
+ payload = {
647
+ 'hub_uid': hub_uid,
648
+ 'hub_secret': hub_secret,
649
+ 'instance_uid': self.c.instance.uid,
650
+ 'mcp-token': getattr(self.mcp_token, 'token', None),
651
+ 'zone': self.zone,
652
+ }
653
+ def _post():
654
+ try:
655
+ return requests.post(url, json=payload, timeout=5)
656
+ except Exception:
657
+ return None
658
+ for delay in (0, 2):
659
+ if delay:
660
+ await asyncio.sleep(delay)
661
+ loop = asyncio.get_running_loop()
662
+ resp = await loop.run_in_executor(None, _post)
663
+ if resp is not None and getattr(resp, 'status_code', None) in (200, 204):
664
+ return
665
+
666
+ async def _end_session(self, cloud_also: bool = False):
667
+ self.active = False
668
+ self.capture_buf.clear()
669
+ self.last_chunk_ts = 0
670
+ self.last_rx_audio_ts = 0
671
+ self.last_tx_audio_ts = 0
672
+ # Reset prewarm/session flags so next VA session can prewarm again
673
+ self._start_session_notified = False
674
+ self._start_session_inflight = False
675
+ self._prewarm_requested = False
676
+ # Close cloud gate so subsequent sessions don't bypass arbitration
677
+ try:
678
+ self._cloud_gate.clear()
679
+ except Exception:
680
+ pass
681
+ for t in (self._finalizer_task, self._cloud_task, self._play_task, self._followup_task):
682
+ if t and not t.done():
683
+ t.cancel()
684
+ self._finalizer_task = self._cloud_task = self._play_task = self._followup_task = None
685
+ await self._set_is_vo_active(False)
686
+ if cloud_also:
687
+ await self._finish_cloud_session()
688
+
689
+ async def shutdown(self):
690
+ await self._end_session(cloud_also=False)
691
+
692
+ async def open_as_winner(self):
693
+ if not self.active:
694
+ self.active = True
695
+ await self._set_is_vo_active(True)
696
+ try:
697
+ self._cloud_gate.set()
698
+ except Exception:
699
+ pass
700
+ # Best-effort notify Website immediately at session start
701
+ # Do it in background so we don't block audio pipeline
702
+ if not self._start_session_notified:
703
+ asyncio.create_task(self._start_cloud_session_safe())
704
+
705
+ async def _start_cloud_session_safe(self):
706
+ if self._start_session_inflight:
707
+ return
708
+ self._start_session_inflight = True
709
+ try:
710
+ await self._start_cloud_session()
711
+ except Exception:
712
+ pass
713
+ else:
714
+ self._start_session_notified = True
715
+ finally:
716
+ self._start_session_inflight = False
717
+
718
+ async def ensure_mcp_token(self):
719
+ """Ensure self.mcp_token exists without toggling is_vo_active."""
720
+ def _execute():
721
+ from simo.mcp_server.models import InstanceAccessToken
722
+ token, _ = InstanceAccessToken.objects.get_or_create(
723
+ instance=self.c.colonel.instance, date_expired=None, issuer='sentinel'
724
+ )
725
+ return token
726
+ self.mcp_token = await sync_to_async(_execute, thread_sensitive=True)()
727
+
728
+ async def prewarm_on_first_audio(self):
729
+ """Called on the first audio frames to notify Website ASAP, before winners."""
730
+ if self._start_session_notified or self._start_session_inflight or self._prewarm_requested:
731
+ return
732
+ self._prewarm_requested = True
733
+ try:
734
+ if self.mcp_token is None:
735
+ await self.ensure_mcp_token()
736
+ except Exception:
737
+ pass
738
+ # Fire and forget; internal flag will be set only on success
739
+ asyncio.create_task(self._start_cloud_session_safe())
740
+
741
+ async def reject_busy(self):
742
+ try:
743
+ await self.c.send_data({'command': 'va', 'session': 'finish', 'status': 'busy'})
744
+ except Exception:
745
+ pass
746
+ await self._end_session(cloud_also=False)
747
+
748
+
749
+ class VoiceAssistantArbitrator:
750
+ """Encapsulates instance-wide arbitration and busy handling for a consumer."""
751
+
752
+ ARBITRATION_WINDOW_MS = 900
753
+ ARBITRATION_RANK_FIELD = 'avg2p5_s' # options: score|snr_db|avg2p5_s|peak2p5_s|energy_1s
754
+ WINNER_CONFIRM_GRACE_MS = 1500
755
+
756
+ def __init__(self, consumer, session: VoiceAssistantSession):
757
+ self.c = consumer
758
+ self.session = session
759
+ self._arb_started = False
760
+ self._arb_task = None
761
+ self._busy_rejected = False
762
+ self._last_active_scan = 0.0
763
+
764
+ async def maybe_reject_busy(self) -> bool:
765
+ now_ts = time.time()
766
+ if (not self._busy_rejected) and (now_ts - self._last_active_scan) > 0.3:
767
+ self._last_active_scan = now_ts
768
+ def _has_active_other():
769
+ return (self.c.colonel.__class__.objects
770
+ .filter(instance=self.c.instance, is_vo_active=True)
771
+ .exclude(id=self.c.colonel.id).exists())
772
+ try:
773
+ active_other = await sync_to_async(_has_active_other, thread_sensitive=True)()
774
+ except Exception:
775
+ active_other = False
776
+ if active_other:
777
+ self._busy_rejected = True
778
+ await self.session.reject_busy()
779
+ return True
780
+ return False
781
+
782
+ def start_window_if_needed(self):
783
+ if not self._arb_started:
784
+ self._arb_started = True
785
+ if self._arb_task and not self._arb_task.done():
786
+ self._arb_task.cancel()
787
+ self._arb_task = asyncio.create_task(self._decide_after_window())
788
+
789
+ async def _decide_after_window(self):
790
+ try:
791
+ await asyncio.sleep(self.ARBITRATION_WINDOW_MS / 1000.0)
792
+ except asyncio.CancelledError:
793
+ return
794
+ await self._decide_arbitration()
795
+
796
+ async def _decide_arbitration(self):
797
+ try:
798
+ await sync_to_async(self.c.colonel.refresh_from_db, thread_sensitive=True)()
799
+ if getattr(self.c.colonel, 'is_vo_active', False):
800
+ await self.session.open_as_winner()
801
+ return
802
+
803
+ def _other_active():
804
+ return (self.c.colonel.__class__.objects
805
+ .filter(instance=self.c.instance, is_vo_active=True)
806
+ .exclude(id=self.c.colonel.id).exists())
807
+ if await sync_to_async(_other_active, thread_sensitive=True)():
808
+ if not self._busy_rejected:
809
+ self._busy_rejected = True
810
+ await self.session.reject_busy()
811
+ return
812
+
813
+ field = getattr(self, 'ARBITRATION_RANK_FIELD', 'avg2p5_s')
814
+ now = timezone.now()
815
+ window_start = now - timedelta(milliseconds=self.ARBITRATION_WINDOW_MS)
816
+
817
+ def _get_candidates():
818
+ qs = self.c.colonel.__class__.objects.filter(
819
+ instance=self.c.instance,
820
+ last_wake__gte=window_start,
821
+ )
822
+ lst = []
823
+ for col in qs:
824
+ stats = getattr(col, 'wake_stats', None) or {}
825
+ val = stats.get(field, -1)
826
+ lst.append((col.id, val))
827
+ return lst
828
+
829
+ cand = await sync_to_async(_get_candidates, thread_sensitive=True)()
830
+ if not cand:
831
+ await self.session.open_as_winner()
832
+ return
833
+ cand.sort(key=lambda t: (t[1], -t[0]))
834
+ chosen_id, _ = cand[-1]
835
+
836
+ if chosen_id == self.c.colonel.id:
837
+ @transaction.atomic
838
+ def _promote_self():
839
+ if self.c.colonel.__class__.objects.select_for_update().filter(
840
+ instance=self.c.instance, is_vo_active=True
841
+ ).exists():
842
+ return False
843
+ cc = self.c.colonel.__class__.objects.select_for_update().get(id=self.c.colonel.id)
844
+ if not cc.is_vo_active:
845
+ cc.is_vo_active = True
846
+ cc.save(update_fields=['is_vo_active'])
847
+ return True
848
+ ok = await sync_to_async(_promote_self, thread_sensitive=True)()
849
+ if ok:
850
+ await self.session.open_as_winner()
851
+ else:
852
+ if not self._busy_rejected:
853
+ self._busy_rejected = True
854
+ await self.session.reject_busy()
855
+ return
856
+
857
+ deadline = time.time() + (self.WINNER_CONFIRM_GRACE_MS / 1000.0)
858
+ while time.time() < deadline:
859
+ def _chosen_active():
860
+ return self.c.colonel.__class__.objects.filter(
861
+ id=chosen_id, instance=self.c.instance, is_vo_active=True
862
+ ).exists()
863
+ def _any_other_active():
864
+ return self.c.colonel.__class__.objects.filter(
865
+ instance=self.c.instance, is_vo_active=True
866
+ ).exclude(id=self.c.colonel.id).exists()
867
+ chosen_active = await sync_to_async(_chosen_active, thread_sensitive=True)()
868
+ if chosen_active:
869
+ if not self._busy_rejected:
870
+ self._busy_rejected = True
871
+ await self.session.reject_busy()
872
+ return
873
+ if await sync_to_async(_any_other_active, thread_sensitive=True)():
874
+ if not self._busy_rejected:
875
+ self._busy_rejected = True
876
+ await self.session.reject_busy()
877
+ return
878
+ await asyncio.sleep(0.1)
879
+
880
+ @transaction.atomic
881
+ def _promote_self_fallback():
882
+ if self.c.colonel.__class__.objects.select_for_update().filter(
883
+ instance=self.c.instance, is_vo_active=True
884
+ ).exists():
885
+ return False
886
+ cc = self.c.colonel.__class__.objects.select_for_update().get(id=self.c.colonel.id)
887
+ if not cc.is_vo_active:
888
+ cc.is_vo_active = True
889
+ cc.save(update_fields=['is_vo_active'])
890
+ return True
891
+ ok = await sync_to_async(_promote_self_fallback, thread_sensitive=True)()
892
+ if ok:
893
+ await self.session.open_as_winner()
894
+ else:
895
+ if not self._busy_rejected:
896
+ self._busy_rejected = True
897
+ await self.session.reject_busy()
898
+ except Exception:
899
+ print(traceback.format_exc(), file=sys.stderr)
900
+ try:
901
+ await self.session.open_as_winner()
902
+ except Exception:
903
+ pass