-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathreceiveAudioHandle.py
More file actions
1251 lines (1086 loc) · 48.2 KB
/
Copy pathreceiveAudioHandle.py
File metadata and controls
1251 lines (1086 loc) · 48.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import re
import time
import json
import asyncio
from difflib import SequenceMatcher
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.connection import ConnectionHandler
from core.utils.util import audio_to_data
from core.handle.abortHandle import handleAbortMessage
from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit
from core.handle.sendAudioHandle import send_stt_message, SentenceType
# DOTTY-PATCH: DeviceCommand seam — monotonic MCP request ids + the envelope
# + per-conn serialized sends, shared with the admin routes in http_server.py
# (mounted at core/utils/device_command.py).
from core.utils.device_command import call_tool as _mcp_call_tool
TAG = __name__
VISION_BRIDGE_URL = os.environ.get("VISION_BRIDGE_URL", "")
MIN_UTTERANCE_CHARS = int(os.environ.get("MIN_UTTERANCE_CHARS", "2"))
# Phase 4 — kid_mode + smart_mode are now firmware-owned toggle pips on the
# right ring (StateManager writes index 8 = warm pink for kid_mode, index 9 =
# orange for smart_mode). The bridge dispatches `self.robot.set_toggle` MCP
# calls on phrase triggers, dashboard flips, and once-per-connection sync.
# State files persist the toggles across daemon restarts and reboots.
#
# These MUST resolve to the same files the bridge dashboard writes. In the
# container deploy DOTTY_KID_MODE_STATE / DOTTY_SMART_MODE_STATE are set (see
# docker-compose.yml.template) to the shared /var/lib/dotty-bridge/state mount.
# The default below matches the bridge container's state dir — NOT the retired
# /root/zeroclaw-bridge RPi path — so the toggles stay in sync even if the env
# vars are missing.
_KID_MODE_STATE_FILE = os.environ.get(
"DOTTY_KID_MODE_STATE", "/var/lib/dotty-bridge/state/kid-mode",
)
_SMART_MODE_STATE_FILE = os.environ.get(
"DOTTY_SMART_MODE_STATE", "/var/lib/dotty-bridge/state/smart-mode",
)
def _read_kid_mode_state() -> bool:
"""Mirror of bridge.py's _read_kid_mode but importable from this module
without circular-import gymnastics. Single source of truth = the same
state file the portal writes. Re-read every turn so dashboard flips land
without a daemon restart."""
try:
with open(_KID_MODE_STATE_FILE, "r") as f:
v = f.read().strip().lower()
if v in ("true", "1", "yes"):
return True
if v in ("false", "0", "no"):
return False
except OSError:
pass
return os.environ.get("DOTTY_KID_MODE", "true").lower() in ("1", "true", "yes")
def _read_smart_mode_state() -> bool:
"""smart_mode is dashboard-gated and persists across reboot via the state
file. The bridge owns the model swap on toggle; this side only reads the
bit so `_sync_toggles_once` can paint the firmware pip on reconnect."""
try:
with open(_SMART_MODE_STATE_FILE, "r") as f:
v = f.read().strip().lower()
if v in ("true", "1", "yes"):
return True
if v in ("false", "0", "no"):
return False
except OSError:
pass
return False
def _write_smart_mode_state(enabled: bool) -> None:
try:
os.makedirs(os.path.dirname(_SMART_MODE_STATE_FILE), exist_ok=True)
with open(_SMART_MODE_STATE_FILE, "w") as f:
f.write("true" if enabled else "false")
except OSError:
pass
_LETTERS_RE = re.compile(r'[a-zA-Z一-鿿-ゟ゠-ヿ]')
_ASR_CORRECTIONS: dict[str, str] = {
"doty": "Dotty",
"dottie": "Dotty",
# Close phonetic substitutions observed in the 2026-07-11 filmed UAT.
# Keep this list conservative: broader variants such as Donny, Jody/Jodi,
# and Claudia are real names and must not be rewritten globally.
"duddy": "Dotty",
"dotie": "Dotty",
"dotti": "Dotty",
"dody": "Dotty",
"daughty": "Dotty",
"haughty": "Dotty",
"naughty": "Dotty",
"hardy": "Dotty",
"darty": "Dotty",
"dolly": "Dotty",
"dougie": "Dotty",
"dory": "Dotty",
"foto": "photo",
"pitcher": "picture",
"pikture": "picture",
"storey": "story",
"danse": "dance",
"mornin": "morning",
"nite": "night",
"singah": "sing a",
}
_ASR_CORRECTION_RE = re.compile(
r'\b(' + '|'.join(re.escape(k) for k in _ASR_CORRECTIONS) + r')\b',
re.IGNORECASE,
)
# ---------- Fuzzy phrase corrections ----------
# Each entry: (canonical_phrase, minimum_similarity_ratio)
# The canonical phrase is what we want. If the ASR text (or a window of it)
# fuzzy-matches above the threshold, we substitute the canonical form.
# Threshold 0.7 is conservative — avoids false positives on short utterances.
_PHRASE_CORRECTIONS: list[tuple[str, float]] = [
# Vision triggers
("take a photo", 0.7),
("take a picture", 0.7),
("take a photo of me", 0.7),
("take a picture of me", 0.7),
# Common kid requests
("tell me a story", 0.7),
("sing a song", 0.7),
("sing the macarena", 0.7),
("dance", 0.8),
("do the macarena", 0.7),
# Song-name fuzzy hits
("play tetris", 0.7),
("hall of the mountain king", 0.7),
("star wars", 0.75),
("pirates of the caribbean", 0.7),
("super mario", 0.7),
("play music", 0.75),
# Identity questions
("what's your name", 0.7),
("what is your name", 0.7),
("who are you", 0.75),
# Greetings
("good morning", 0.7),
("good night", 0.7),
]
def _apply_phrase_corrections(text: str) -> str:
"""Fuzzy-match ASR text against known phrases and substitute if close enough.
Uses a sliding window: for each canonical phrase of N words, we check every
contiguous N-word window in the ASR text. If the best window exceeds the
similarity threshold, we replace that window with the canonical phrase.
Only the single best match (highest ratio) is applied per call to avoid
cascading replacements on short utterances.
"""
lower = text.lower().strip()
words = lower.split()
if len(words) < 2:
return text # too short to fuzzy-match phrases
best_ratio = 0.0
best_phrase = ""
best_start = 0
best_length = 0
for canonical, threshold in _PHRASE_CORRECTIONS:
canon_words = canonical.split()
window_size = len(canon_words)
if window_size > len(words):
continue
for i in range(len(words) - window_size + 1):
window = " ".join(words[i : i + window_size])
ratio = SequenceMatcher(None, window, canonical).ratio()
if ratio >= threshold and ratio > best_ratio:
best_ratio = ratio
best_phrase = canonical
best_start = i
best_length = window_size
if best_ratio > 0:
# Rebuild using original-case words outside the match window,
# substituting the canonical phrase for the matched span.
original_words = text.split()
# Map word indices from lower-cased split back to original split.
# They should align since we only called .lower() without changing
# word boundaries, but guard against edge cases.
if len(original_words) >= best_start + best_length:
before = " ".join(original_words[:best_start])
after = " ".join(original_words[best_start + best_length :])
parts = [p for p in (before, best_phrase, after) if p]
return " ".join(parts)
return text
def _is_noise(text: str) -> bool:
stripped = text.strip()
if not stripped or len(stripped) < MIN_UTTERANCE_CHARS:
return True
return len(_LETTERS_RE.findall(stripped)) < MIN_UTTERANCE_CHARS
def _apply_asr_corrections(text: str) -> str:
def _repl(m):
return _ASR_CORRECTIONS.get(m.group(0).lower(), m.group(0))
return _ASR_CORRECTION_RE.sub(_repl, text)
VISION_PHRASES = (
"look at", "what do you see", "what is this", "what's this",
"take a photo", "take a picture", "can you see", "what's in front",
"what am i holding", "what's that", "what is that", "describe what",
"what color is", "what colour is", "how many", "do you see",
)
# Phase 4 — state-trigger phrases. Each entry: (substring, target_state, ack).
# Order matters: longer/more-specific phrases first so "good night dotty" beats
# "good night". Match is case-insensitive substring on the full ASR text.
#
# Phase 5/6/7 wire the actual behaviour (sleep posing, security scanning, story
# narration). Phase 4 just dispatches set_state and emits a brief LLM ack.
_STATE_TRIGGER_PHRASES: tuple[tuple[str, str, str], ...] = (
("goodnight dotty", "sleep", "Goodnight! \U0001f634"),
("good night dotty", "sleep", "Goodnight! \U0001f634"),
("go to sleep", "sleep", "Going to sleep \U0001f634"),
("keep watch", "security", "Watching the room \U0001f47e"),
("security mode", "security", "Watching the room \U0001f47e"),
("watch the room", "security", "Watching the room \U0001f47e"),
("tell me a story", "story_time", "Story time! \U0001f4d6"),
("story time", "story_time", "Story time! \U0001f4d6"),
)
# "wake up" / "come back" / "are you there" only switch state if Dotty is in a
# non-conversational state (sleep / security / story_time). Otherwise they're
# regular conversation continuations.
_WAKE_PHRASES = ("wake up", "come back", "are you there")
_NON_CONVERSATIONAL_STATES = ("sleep", "security", "story_time")
def _detect_state_phrase(text: str) -> tuple[str, str] | None:
lower = text.lower().strip()
for phrase, state, ack in _STATE_TRIGGER_PHRASES:
if phrase in lower:
return (state, ack)
return None
def _is_wake_phrase(text: str) -> bool:
lower = text.lower().strip()
return any(phrase in lower for phrase in _WAKE_PHRASES)
_HELP_PHRASES = (
"what can you do",
"what do you do",
"how do i use you",
"how do you work",
"help me out",
"what are your features",
)
def _is_help_request(text: str) -> bool:
lower = text.lower().strip()
return any(phrase in lower for phrase in _HELP_PHRASES)
async def _send_led_color(conn: "ConnectionHandler", r: int, g: int, b: int) -> None:
try:
await _mcp_call_tool(
conn, "self.robot.set_led_color",
{"red": r, "green": g, "blue": b},
)
except Exception:
pass
# Phase 4 — kid_mode + smart_mode pips are firmware-owned (StateManager
# 5 Hz re-assert at right ring 8/9). The firmware restores the pips after
# every chat-state full-ring write within ~200 ms.
async def _send_led_multi(
conn: "ConnectionHandler", index: int, r: int, g: int, b: int,
) -> None:
"""Set a SINGLE pixel on the neon-ring without disturbing the rest.
Wraps the firmware MCP `self.robot.set_led_multi` tool (firmware
≥ 32163bd). Index 0-5 = LeftNeonLight, 6-11 = RightNeonLight. This
bypasses the firmware's colour-animation tick, so callers that need
the pixel to PERSIST across a subsequent set_led_color must re-call
this after each full-ring update.
Defensive: try/except guarded so an old firmware (without this MCP
tool) degrades to the existing single-flash behaviour rather than
crashing the LLM flow. Logs a warning on first failure per session
so we don't noisily spam.
"""
try:
await _mcp_call_tool(
conn, "self.robot.set_led_multi",
{"index": index, "red": r, "green": g, "blue": b},
)
except Exception as exc:
# The firmware may simply not support set_led_multi yet (old
# build); log warn-once per connection so we know without
# spamming on every re-assert.
if not getattr(conn, "_led_multi_warned", False):
try:
conn.logger.bind(tag=TAG).warning(
f"set_led_multi failed (firmware may pre-date 32163bd): {exc}"
)
except Exception:
pass
conn._led_multi_warned = True
async def _send_head_angles(conn: "ConnectionHandler", yaw: int, pitch: int, speed: int = 150) -> None:
try:
await _mcp_call_tool(
conn, "self.robot.set_head_angles",
{"yaw": yaw, "pitch": pitch, "speed": speed},
)
except Exception:
pass
async def _send_set_state(conn: "ConnectionHandler", state: str) -> None:
"""Phase 4 — fire `self.robot.set_state` MCP at the firmware. Valid states:
idle / talk / story_time / security / sleep / dance. The firmware
StateManager handles the transition (pip update + idle profile + state_changed
event back to the bridge)."""
# Record intent before the first await. Dance cleanup consults this value
# so a cancelled/finishing choreography cannot restore IDLE over a newer
# sleep/security/dance request while its MCP send is in flight.
conn._dotty_desired_state = state
try:
await _mcp_call_tool(conn, "self.robot.set_state", {"state": state})
except Exception as exc:
try:
conn.logger.bind(tag=TAG).warning(f"set_state {state} failed: {exc}")
except Exception:
pass
async def _send_set_toggle(conn: "ConnectionHandler", name: str, enabled: bool) -> None:
"""Phase 4 — fire `self.robot.set_toggle` MCP at the firmware. Valid names:
kid_mode (warm pink pip on right ring index 8) and smart_mode (orange pip
on right ring index 9). Toggles compose freely with state."""
try:
await _mcp_call_tool(
conn, "self.robot.set_toggle", {"name": name, "enabled": enabled},
)
except Exception as exc:
try:
conn.logger.bind(tag=TAG).warning(f"set_toggle {name}={enabled} failed: {exc}")
except Exception:
pass
async def _sync_toggles_once(conn: "ConnectionHandler") -> None:
"""Push current kid_mode + smart_mode state to the firmware once per
WebSocket connection. The firmware StateManager boots with both toggles
OFF; this resync runs on the first turn after a reconnect (or daemon
restart) so the toggle pips reflect the bridge's persisted state.
Idempotent — repeat calls are no-ops via the `_dotty_toggles_synced`
sentinel. Dashboard flips push their own set_toggle MCP calls; smart_mode
is dashboard-only by design."""
if getattr(conn, "_dotty_toggles_synced", False):
return
conn._dotty_toggles_synced = True
kid_on = _read_kid_mode_state()
smart_on = _read_smart_mode_state()
await _send_set_toggle(conn, "kid_mode", kid_on)
await _send_set_toggle(conn, "smart_mode", smart_on)
try:
conn.logger.bind(tag=TAG).info(
f"toggles synced on reconnect: kid_mode={kid_on} smart_mode={smart_on}"
)
except Exception:
pass
def _is_vision_request(text: str) -> bool:
lower = text.lower().strip()
return any(phrase in lower for phrase in VISION_PHRASES)
async def _handle_vision(conn: "ConnectionHandler", text: str) -> str | None:
if not VISION_BRIDGE_URL:
conn.logger.bind(tag=TAG).warning("VISION_BRIDGE_URL not set, skipping vision")
return None
device_id = conn.headers.get("device-id", "unknown")
await _mcp_call_tool(conn, "self.camera.take_photo", {"question": text})
conn.logger.bind(tag=TAG).info(f"Vision: sent take_photo MCP call, device={device_id}")
try:
import requests
url = f"{VISION_BRIDGE_URL.rstrip('/')}/api/vision/latest/{device_id}"
resp = await asyncio.get_event_loop().run_in_executor(
None,
lambda: requests.get(url, timeout=20),
)
if resp.status_code == 200:
description = resp.json().get("description", "")
conn.logger.bind(tag=TAG).info(f"Vision: got description len={len(description)}")
return description
except Exception as exc:
conn.logger.bind(tag=TAG).error(f"Vision: bridge poll failed: {exc}")
return None
# ---------- Description-based identity (no storage) ----------
# Capture a one-line natural-language description of who is currently
# in front of the camera, cache it on `conn`, surface it to the bridge
# via a `[ROOM_VIEW]\n<desc>\n` prefix on the next user turn so the
# bridge can render `[Room view] ...` into the prompt. No biometric
# data is stored anywhere — the cache is per-connection and cleared on
# face_lost (see textMessageHandlerRegistry.EventTextMessageHandler).
#
# Capture is fire-and-forget: triggered by the perception relay on
# face_detected, runs in the background, beats the user's first voice
# turn most of the time. If a turn arrives before capture completes,
# that turn just goes out without a [Room view] line — no extra
# latency on the voice path.
# Sentinel placed in the multipart `question` field by the
# `take_photo` MCP call to opt in to the bridge's roster-aware
# room_view path. The actual prompt + household roster live on the
# bridge (see `bridge.py:_build_room_view_question` +
# `_ROOM_VIEW_SENTINEL`). The xiaozhi side stays roster-agnostic.
# Versioning is in the sentinel itself for future format revs.
_ROOM_VIEW_VLM_QUESTION = "__ROOM_VIEW_V1__"
# Sentinel reply the bridge emits when the frame is empty / no person
# is visible. Treated as "no description" so we don't stuff a useless
# [Room view] line into the next voice turn. Mirrors the bridge-side
# constant of the same name.
_ROOM_VIEW_NO_PERSON = "no one in view"
async def _capture_room_description_async(
conn: "ConnectionHandler",
) -> None:
"""Background-capture the current room view description.
Called from the perception relay on `face_detected` (when no fresh
description is cached). Sends a `take_photo` MCP call with a
description-focused VLM question, long-polls the bridge for the
result, and caches it on `conn._room_description`. Best-effort —
a failure leaves the cache empty and the next voice turn proceeds
without `[Room view]`.
"""
if not VISION_BRIDGE_URL:
return
device_id = "unknown"
try:
device_id = conn.headers.get("device-id", "unknown")
except Exception:
pass
# Mark in-flight so concurrent face_detected events don't trigger
# a second capture on top of an active one. Cleared in finally.
if getattr(conn, "_room_description_in_flight", False):
return
conn._room_description_in_flight = True
try:
import requests
url = f"{VISION_BRIDGE_URL.rstrip('/')}/api/vision/latest/{device_id}"
# Background captures lose most races against the live face
# detector for the firmware camera arbiter (Capture() returns
# false on lock timeout → firmware never POSTs to bridge →
# bridge long-poll times out at 15s with 404). Re-send the MCP
# call on miss; each retry gives the detector another window
# to release the lock between its own ticks. Worst case ~50s
# for a fire-and-forget background capture — fine because
# late landing just means [Room view] attaches to a later
# voice turn instead of the first one. See
# probes/identity-description-flow.md and the [~] note in
# tasks.md §Layer 4 v1.5 for the full diagnosis.
body: dict | None = None
for attempt in range(3):
await _mcp_call_tool(
conn, "self.camera.take_photo",
{"question": _ROOM_VIEW_VLM_QUESTION},
)
if attempt == 0:
conn.logger.bind(tag=TAG).info(
f"room_view: capture started device={device_id}"
)
resp = None
try:
resp = await asyncio.get_event_loop().run_in_executor(
None,
lambda: requests.get(url, timeout=20),
)
except Exception as exc:
conn.logger.bind(tag=TAG).warning(
f"room_view: bridge poll exc attempt={attempt + 1}: {exc}"
)
if resp is not None and resp.status_code == 200:
candidate = resp.json() or {}
if (candidate.get("description") or "").strip():
body = candidate
break
status = resp.status_code if resp is not None else "exception"
conn.logger.bind(tag=TAG).info(
f"room_view: miss attempt={attempt + 1} status={status}"
)
if attempt < 2:
await asyncio.sleep(0.3 * (2 ** attempt))
if not body:
conn.logger.bind(tag=TAG).warning(
f"room_view: capture failed after retries device={device_id}"
)
return
description = (body.get("description") or "").strip()
# `room_match_person_id` is added by the bridge's room_view
# path; v1 description-only callers won't see it. Empty / None /
# "unknown" all reduce to "no roster match this turn".
match_raw = body.get("room_match_person_id")
match = (match_raw or "").strip().lower() or None
if match == "unknown":
match = None
if not description:
return
# Treat the "no one in view" sentinel as a miss so we don't
# stuff the prompt with a useless line.
if _ROOM_VIEW_NO_PERSON in description.lower():
conn._room_description = None
conn._room_match_person_id = None
conn.logger.bind(tag=TAG).info(
"room_view: VLM reports no person; cache cleared"
)
return
conn._room_description = description
conn._room_match_person_id = match
conn._room_description_ts = time.time()
conn.logger.bind(tag=TAG).info(
f"room_view: cached len={len(description)} "
f"match={match or '-'} "
f"preview={description[:60]!r}"
)
except Exception as exc:
conn.logger.bind(tag=TAG).warning(
f"room_view: capture failed: {exc}"
)
finally:
conn._room_description_in_flight = False
def _with_room_view_marker(
conn: "ConnectionHandler", text: str,
) -> str:
"""Prepend the [ROOM_VIEW] marker to `text` if a fresh room
description is cached on `conn`.
Two shapes (zeroclaw `_payload` accepts both):
v1 — description only:
[ROOM_VIEW]\\n<desc>\\n<text>
v2 — description + roster match (may be empty):
[ROOM_VIEW]\\n<desc>\\n<person_id_or_blank>\\n<text>
We always emit v2 when a room description is cached. The
person_id slot is the matched roster id (e.g. `hudson`) or empty
string for "no roster match" — both are valid v2 signals. The
zeroclaw provider strips the marker and pushes both fields into
request metadata; the bridge's SpeakerResolver consumes the
person_id as a vote (see `_resolve_speaker_for_request`)."""
desc = getattr(conn, "_room_description", None)
if not desc:
return text
match = getattr(conn, "_room_match_person_id", None) or ""
return f"[ROOM_VIEW]\n{desc}\n{match}\n{text}"
def _submit_chat(conn: "ConnectionHandler", text: str) -> None:
"""Submit `text` to the LLM via the connection's executor, with
the room-view marker prepended automatically when a fresh
description is cached. Single chokepoint for description
propagation — every voice path goes through here.
"""
conn.executor.submit(conn.chat, _with_room_view_marker(conn, text))
# ---------- Dance / singing mode ----------
# Singing and dancing are unified — both route to _handle_dance(), which plays
# the choreography and (if a matching audio_file exists in DANCE_REGISTRY)
# injects pre-rendered singing audio into the TTS queue.
_DANCE_PHRASES = (
"dance", "do a dance", "let's dance", "can you dance",
"dance for me", "dance time", "dance mode",
# Macarena
"do the macarena", "macarena",
# Singing triggers — same handler, audio file decides if it sings.
"sing a song", "sing the macarena", "sing macarena",
"can you sing", "sing for me", "sing something",
"let's sing",
# Other songs in the catalog
"play tetris", "tetris music", "play the tetris",
"mountain king", "hall of the mountain king",
"star wars", "play star wars", "star wars music",
"pirates", "pirate music", "pirates of the caribbean",
"play mario", "mario music", "super mario",
"play music", "play a song", "music time",
)
# Short "sing" needs word-boundary matching to avoid false positives on
# words like "single" or "singapore".
_SING_WORD_RE = re.compile(r"\bsing\b", re.IGNORECASE)
def _is_dance_request(text: str) -> bool:
lower = text.lower().strip()
if any(phrase in lower for phrase in _DANCE_PHRASES):
return True
return bool(_SING_WORD_RE.search(lower))
# Map spoken-form aliases → registry key. First-match wins, longest first
# so "mountain king" beats "king".
_DANCE_ALIASES: tuple[tuple[str, str], ...] = (
("hall of the mountain king", "mountain_king"),
("mountain king", "mountain_king"),
("pirates of the caribbean", "pirates"),
("super mario", "mario"),
("star wars", "star_wars"),
("macarena", "macarena"),
("tetris", "tetris"),
("pirate", "pirates"),
("mario", "mario"),
)
def _detect_dance_name(text: str) -> str:
from core.handle.dances import DANCE_REGISTRY, DEFAULT_DANCE
import random
lower = text.lower()
# Direct registry-key hit (handles "macarena", "tetris" already).
for name in DANCE_REGISTRY:
if name in lower:
return name
# Aliased names (multi-word, underscored, etc.)
for alias, name in _DANCE_ALIASES:
if alias in lower:
return name
# Generic "play music" / "play a song" / "music time" → random pick.
if any(p in lower for p in ("play music", "play a song", "music time", "play song")):
return random.choice(list(DANCE_REGISTRY.keys()))
return DEFAULT_DANCE
async def _handle_dance(conn: "ConnectionHandler", dance_name: str) -> None:
from core.handle.dances import DANCE_REGISTRY, execute_choreography, resolve_timeline
dance = DANCE_REGISTRY.get(dance_name)
if not dance:
return
conn.logger.bind(tag=TAG).info(f"Dance mode: {dance_name}")
# Enter the firmware's sticky dance state before starting the server-side
# song timeline, making the state/event contract truthful for downstream
# consumers. Servo ownership during DANCE is a firmware responsibility.
dance_generation = getattr(conn, "_dotty_dance_generation", 0) + 1
conn._dotty_dance_generation = dance_generation
await _send_set_state(conn, "dance")
await conn.websocket.send(json.dumps({
"type": "llm",
"text": "\U0001f606",
"emotion": "laughing",
"session_id": conn.session_id,
}))
await _send_led_color(conn, 168, 0, 168)
audio_file = dance.get("audio_file")
has_audio = bool(audio_file) and os.path.exists(audio_file)
opus_packets = None
if has_audio:
try:
ext = os.path.splitext(audio_file)[1].lower()
if ext in (".mid", ".midi"):
opus_packets = await _encode_midi_to_opus(
audio_file,
conn.sample_rate,
target_tempo_bpm=dance.get("audio_tempo_bpm"),
max_duration_ms=dance.get("duration_ms"),
)
else:
opus_packets = await _encode_song_to_opus(audio_file, conn.sample_rate)
except Exception as exc:
conn.logger.bind(tag=TAG).error(f"Dance mode: audio decode failed: {exc}")
has_audio = False
# Only delay choreography for audio sync when we actually queued audio.
from core.handle.dances import AUDIO_LATENCY_OFFSET_MS
audio_offset = AUDIO_LATENCY_OFFSET_MS if has_audio else 0
timeline = resolve_timeline(dance)
dance_task = asyncio.create_task(
_run_owned_dance_choreography(
conn, dance_generation, timeline, execute_choreography,
audio_latency_offset_ms=audio_offset,
)
)
conn._dance_task = dance_task
if has_audio and opus_packets is not None:
# Direct send: bypass tts_audio_queue and the rate controller. The
# consumer's future.result(timeout=tts_timeout) trips on a 28-second
# clip (tts_timeout defaults to 15s), and the upstream audio_to_data
# hardcodes 16kHz Opus regardless of the negotiated output rate. Pace
# by sleeping 60ms between packets, matching the device's
# frame_duration handshake parameter.
conn.client_abort = False
conn.client_is_speaking = True
asyncio.create_task(_stream_singing(conn, opus_packets))
conn.logger.bind(tag=TAG).info(
f"Dance mode: streaming singing audio {audio_file} "
f"({len(opus_packets)} packets @ {conn.sample_rate}Hz)"
)
else:
conn.executor.submit(
conn.chat,
f"[DANCE:{dance_name}] You're about to dance the {dance_name.title()}! "
f"Say a SHORT excited one-liner intro (under 15 words). "
f"Example: '\U0001f606 {dance['intro']}'",
)
async def _run_owned_dance_choreography(
conn: "ConnectionHandler",
generation: int,
timeline: list[tuple[int, str, dict]],
execute_choreography,
*,
audio_latency_offset_ms: int,
) -> None:
"""Run and clean up one dance without overwriting a successor state.
Cleanup belongs to this task (rather than a detached done-callback), so
callers can await cancellation and know all owned cleanup has finished.
The generation prevents an older dance cleaning up a replacement dance;
desired-state tracking protects security, sleep, and admin changes.
"""
cancelled = False
try:
await execute_choreography(
conn, timeline, _send_head_angles, _send_led_color,
audio_latency_offset_ms=audio_latency_offset_ms,
)
except asyncio.CancelledError:
cancelled = True
finally:
owns_generation = (
getattr(conn, "_dotty_dance_generation", None) == generation
)
desired_state = getattr(conn, "_dotty_desired_state", "dance")
# Firmware's state_changed echo is asynchronous and may still report
# IDLE when a very short choreography finishes. Desired state is set
# synchronously before every local MCP request and is also refreshed
# by state_changed events (including dashboard/admin transitions).
still_dancing = desired_state == "dance"
if owns_generation and still_dancing:
if cancelled:
await _send_head_angles(conn, 0, 0, 200)
await _send_led_color(conn, 0, 0, 0)
await _send_set_state(conn, "idle")
if cancelled:
raise asyncio.CancelledError
async def _cancel_active_dance(conn: "ConnectionHandler", dance_task) -> None:
"""Cancel and fully clean up a dance before processing its successor.
Invalidate ownership before yielding so the task's ``finally`` cannot
race an IDLE write with a later security/sleep request. The call site
awaits this function before abort handling and successor intent parsing.
"""
conn._dotty_dance_generation = (
getattr(conn, "_dotty_dance_generation", 0) + 1
)
conn._dotty_desired_state = "idle"
dance_task.cancel()
try:
await dance_task
except asyncio.CancelledError:
pass
await _send_head_angles(conn, 0, 0, 200)
await _send_led_color(conn, 0, 0, 0)
await _send_set_state(conn, "idle")
_MIDI_RENDER_CACHE: dict[tuple, list[bytes]] = {}
FLUID_SOUNDFONT = "/usr/share/sounds/sf2/FluidR3_GM.sf2"
async def _encode_midi_to_opus(
midi_path: str,
target_rate: int,
target_tempo_bpm: float | None = None,
max_duration_ms: int | None = None,
) -> list[bytes]:
"""Render a MIDI file to Opus 60ms frames via fluidsynth.
Cached in-memory by (midi_path, mtime, target_rate, tempo, duration) so a
repeat dance is instant. Optionally rewrites the MIDI's tempo events
(`target_tempo_bpm`) so the music matches the choreography BPM.
"""
import os as _os
mtime = _os.path.getmtime(midi_path)
cache_key = (midi_path, mtime, target_rate, target_tempo_bpm, max_duration_ms)
cached = _MIDI_RENDER_CACHE.get(cache_key)
if cached is not None:
return cached
def _render():
import subprocess
import tempfile
import wave as _wave
import numpy as np
from scipy import signal as _scipy_signal
from math import gcd
from core.utils import opus_encoder_utils
with tempfile.TemporaryDirectory() as tmpdir:
mid_to_render = midi_path
if target_tempo_bpm is not None:
import mido
src = mido.MidiFile(midi_path)
new_tempo = mido.bpm2tempo(target_tempo_bpm)
for track in src.tracks:
has_tempo = False
for msg in track:
if msg.type == "set_tempo":
msg.tempo = new_tempo
has_tempo = True
if not has_tempo and track is src.tracks[0]:
track.insert(0, mido.MetaMessage("set_tempo", tempo=new_tempo, time=0))
mid_to_render = f"{tmpdir}/retempo.mid"
src.save(mid_to_render)
wav_path = f"{tmpdir}/render.wav"
subprocess.run(
[
"fluidsynth", "-ni",
"-r", str(target_rate),
"-g", "0.7",
"-F", wav_path,
FLUID_SOUNDFONT,
mid_to_render,
],
check=True, capture_output=True, timeout=60,
)
with _wave.open(wav_path, "rb") as wf:
src_rate = wf.getframerate()
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
raw = wf.readframes(wf.getnframes())
if sampwidth != 2:
raise RuntimeError(f"fluidsynth produced sampwidth={sampwidth}, expected 2")
pcm = np.frombuffer(raw, dtype=np.int16)
if channels == 2:
pcm = pcm.reshape(-1, 2).mean(axis=1).astype(np.int16)
if src_rate != target_rate:
g = gcd(src_rate, target_rate)
up = target_rate // g
down = src_rate // g
pcm = _scipy_signal.resample_poly(pcm.astype(np.float32), up, down)
pcm = np.clip(pcm, -32768, 32767).astype(np.int16)
if max_duration_ms is not None:
max_samples = int(max_duration_ms / 1000.0 * target_rate)
if len(pcm) > max_samples:
pcm = pcm[:max_samples]
elif len(pcm) < max_samples:
pcm = np.concatenate([pcm, np.zeros(max_samples - len(pcm), dtype=np.int16)])
encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=target_rate, channels=1, frame_size_ms=60
)
frame_samples = int(target_rate * 60 / 1000)
frame_bytes = frame_samples * 2
pcm_bytes = pcm.tobytes()
packets: list[bytes] = []
def _collect(opus_bytes):
if opus_bytes:
packets.append(opus_bytes)
for i in range(0, len(pcm_bytes), frame_bytes):
chunk = pcm_bytes[i : i + frame_bytes]
if len(chunk) < frame_bytes:
chunk += b"\x00" * (frame_bytes - len(chunk))
encoder.encode_pcm_to_opus_stream(
chunk, end_of_stream=(i + frame_bytes >= len(pcm_bytes)),
callback=_collect,
)
encoder.close()
return packets
packets = await asyncio.get_running_loop().run_in_executor(None, _render)
_MIDI_RENDER_CACHE[cache_key] = packets
return packets
async def _encode_song_to_opus(wav_path: str, target_rate: int) -> list[bytes]:
"""Read a WAV file and return Opus-encoded 60ms frames at target_rate.
The upstream audio_to_data() hardcodes 16kHz, but the device negotiates
a different output rate via the welcome handshake (24kHz on this StackChan).
Decoding 16kHz Opus when the device expects 24kHz silently produces no
audible output. This helper resamples to target_rate and encodes Opus at
the same rate, matching what Piper's TTS provider does.
"""
from core.utils import opus_encoder_utils
import numpy as np
from scipy import signal as scipy_signal
from math import gcd
from pydub import AudioSegment
def _decode_and_encode():
audio = AudioSegment.from_file(
wav_path, format="wav", parameters=["-nostdin"]
)
audio = audio.set_channels(1).set_sample_width(2)
src_rate = audio.frame_rate
pcm = np.frombuffer(audio.raw_data, dtype=np.int16)
if src_rate != target_rate:
g = gcd(src_rate, target_rate)
up = target_rate // g
down = src_rate // g
resampled = scipy_signal.resample_poly(pcm, up, down)
pcm = np.clip(resampled, -32768, 32767).astype(np.int16)
encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=target_rate, channels=1, frame_size_ms=60
)
frame_samples = int(target_rate * 60 / 1000)
frame_bytes = frame_samples * 2
pcm_bytes = pcm.tobytes()
packets: list[bytes] = []
def _collect(opus_bytes):
if opus_bytes:
packets.append(opus_bytes)
for i in range(0, len(pcm_bytes), frame_bytes):
chunk = pcm_bytes[i : i + frame_bytes]
if len(chunk) < frame_bytes:
chunk += b"\x00" * (frame_bytes - len(chunk))
encoder.encode_pcm_to_opus_stream(
chunk, end_of_stream=(i + frame_bytes >= len(pcm_bytes)),
callback=_collect,
)
encoder.close()
return packets
return await asyncio.get_running_loop().run_in_executor(None, _decode_and_encode)
async def _stream_singing(conn: "ConnectionHandler", opus_packets: list) -> None:
"""Send a list of Opus packets to the device with 60 ms pacing.
Bypasses tts_audio_queue because the consumer's future.result
(tts_timeout=15s default) trips on long clips. Sends packets directly to
the WebSocket, paced by asyncio.sleep. Respects client_abort for barge-in.
"""
frame_s = 0.06
sent = 0
try:
# Match what sendAudioMessage(FIRST, ...) does: emit a sentence_start
# so the device firmware transitions into "playing" state. Without
# this, Opus frames arrive but get dropped on the floor.
await conn.websocket.send(json.dumps({
"type": "tts",
"state": "sentence_start",
"text": "Macarena",
"session_id": conn.session_id,
}))
for packet in opus_packets: