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

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