stackchan-mcp 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/PKG-INFO +3 -1
  2. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/pyproject.toml +3 -1
  3. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/cli.py +4 -0
  4. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stdio_server.py +168 -14
  5. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/__init__.py +7 -0
  6. stackchan_mcp-0.13.0/stackchan_mcp/tts/edge_tts.py +158 -0
  7. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/orchestrator.py +2 -0
  8. stackchan_mcp-0.13.0/stackchan_mcp/user_defaults.py +254 -0
  9. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stdio_server.py +368 -0
  10. stackchan_mcp-0.13.0/tests/test_user_defaults.py +99 -0
  11. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/uv.lock +14 -1
  12. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/.env.example +0 -0
  13. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/.gitignore +0 -0
  14. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/AGENTS.md +0 -0
  15. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/LICENSE +0 -0
  16. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/LICENSE-THIRD-PARTY +0 -0
  17. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/README.md +0 -0
  18. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__init__.py +0 -0
  19. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__main__.py +0 -0
  20. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
  21. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_input_hook.py +0 -0
  22. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_stream.py +0 -0
  23. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/capture_server.py +0 -0
  24. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/esp32_client.py +0 -0
  25. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/event_log.py +0 -0
  26. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/follow_pose_stream.py +0 -0
  27. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/gateway.py +0 -0
  28. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/__init__.py +0 -0
  29. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/audio.py +0 -0
  30. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/camera.py +0 -0
  31. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/robot.py +0 -0
  32. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/http_server.py +0 -0
  33. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mcp_router.py +0 -0
  34. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mdns_advertiser.py +0 -0
  35. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify.example.yml +0 -0
  36. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify_config.py +0 -0
  37. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/ownership.py +0 -0
  38. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/protocol.py +0 -0
  39. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/queue.py +0 -0
  40. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/server.py +0 -0
  41. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/__init__.py +0 -0
  42. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/audio_utils.py +0 -0
  43. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/base.py +0 -0
  44. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
  45. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
  46. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/orchestrator.py +0 -0
  47. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tools.py +0 -0
  48. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/audio_utils.py +0 -0
  49. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/base.py +0 -0
  50. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
  51. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/irodori.py +0 -0
  52. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/voicevox.py +0 -0
  53. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/_audio_fixtures.py +0 -0
  54. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/conftest.py +0 -0
  55. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_input_hook.py +0 -0
  56. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_stream.py +0 -0
  57. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_utils.py +0 -0
  58. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_capture_server.py +0 -0
  59. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_cli.py +0 -0
  60. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_emoji_expression.py +0 -0
  61. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_esp32_client.py +0 -0
  62. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_event_dispatch.py +0 -0
  63. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_event_log.py +0 -0
  64. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_follow_pose_stream.py +0 -0
  65. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_gateway.py +0 -0
  66. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_http_server.py +0 -0
  67. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_irodori.py +0 -0
  68. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_mcp_router.py +0 -0
  69. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_mdns_advertiser.py +0 -0
  70. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_notify_config.py +0 -0
  71. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_orchestrator.py +0 -0
  72. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_ownership.py +0 -0
  73. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_protocol.py +0 -0
  74. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_audio.py +0 -0
  75. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_stream.py +0 -0
  76. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stackchan_event.py +0 -0
  77. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_audio_utils.py +0 -0
  78. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_framework.py +0 -0
  79. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_orchestrator.py +0 -0
  80. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_tts_framework.py +0 -0
  81. {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_voicevox.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackchan-mcp
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP.
5
5
  Project-URL: Homepage, https://github.com/kisaragi-mochi/stackchan-mcp
6
6
  Project-URL: Repository, https://github.com/kisaragi-mochi/stackchan-mcp
@@ -25,9 +25,11 @@ Classifier: Topic :: System :: Hardware
25
25
  Requires-Python: >=3.10
26
26
  Requires-Dist: aiohttp>=3
27
27
  Requires-Dist: mcp<2.0,>=1.27
28
+ Requires-Dist: platformdirs<5.0,>=4.0
28
29
  Requires-Dist: pydantic>=2
29
30
  Requires-Dist: python-dotenv
30
31
  Requires-Dist: pyyaml>=6
32
+ Requires-Dist: tomli<3.0,>=2.0; python_version < '3.11'
31
33
  Requires-Dist: websockets>=12
32
34
  Requires-Dist: zeroconf>=0.149
33
35
  Provides-Extra: stt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackchan-mcp"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "Two-faced MCP gateway for StackChan (xiaozhi-esp32): bridges stdio MCP clients to the ESP32 over WebSocket + HTTP."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -34,6 +34,8 @@ dependencies = [
34
34
  "pydantic>=2",
35
35
  "python-dotenv",
36
36
  "PyYAML>=6",
37
+ "platformdirs>=4.0,<5.0",
38
+ "tomli>=2.0,<3.0; python_version < '3.11'",
37
39
  # The stdio server captures the active ServerSession via a subclassed
38
40
  # Server.run() loop because the public MCP SDK does not yet expose a
39
41
  # stable hook for server-side notifications. That subclass relies on
@@ -745,6 +745,10 @@ def _configure_gateway_startup() -> None:
745
745
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
746
746
  )
747
747
 
748
+ from .user_defaults import log_user_defaults_startup
749
+
750
+ log_user_defaults_startup()
751
+
748
752
 
749
753
  def _acquire_startup_lock(
750
754
  *,
@@ -21,6 +21,7 @@ from mcp.types import Notification, TextContent, Tool
21
21
  from . import __version__
22
22
  from .gateway import get_gateway
23
23
  from .notify_config import NotifyConfig, load_notify_config
24
+ from .user_defaults import resolve_default
24
25
  from .stt import listen_and_transcribe
25
26
  from .tts import synthesize_and_send
26
27
 
@@ -388,30 +389,64 @@ async def _handle_follow_pose_stream(
388
389
  if not (url.startswith("ws://") or url.startswith("wss://")):
389
390
  return _follow_pose_error("url must start with ws:// or wss://")
390
391
 
391
- flip_yaw = arguments.get("flip_yaw", 1)
392
+ tool_name = "stackchan_follow_pose_stream"
393
+
394
+ flip_yaw = (
395
+ arguments["flip_yaw"]
396
+ if "flip_yaw" in arguments
397
+ else resolve_default(tool_name, "flip_yaw", 1)
398
+ )
392
399
  if not _is_int_arg(flip_yaw) or flip_yaw not in (-1, 1):
393
400
  return _follow_pose_error("flip_yaw must be -1 or 1")
394
401
 
395
- flip_pitch = arguments.get("flip_pitch", 1)
402
+ flip_pitch = (
403
+ arguments["flip_pitch"]
404
+ if "flip_pitch" in arguments
405
+ else resolve_default(tool_name, "flip_pitch", 1)
406
+ )
396
407
  if not _is_int_arg(flip_pitch) or flip_pitch not in (-1, 1):
397
408
  return _follow_pose_error("flip_pitch must be -1 or 1")
398
409
 
399
- pitch_center_deg = arguments.get("pitch_center_deg", 45)
410
+ pitch_center_deg = (
411
+ arguments["pitch_center_deg"]
412
+ if "pitch_center_deg" in arguments
413
+ else resolve_default(tool_name, "pitch_center_deg", 45)
414
+ )
400
415
  if (
401
416
  not _is_int_arg(pitch_center_deg)
402
417
  or not 5 <= pitch_center_deg <= 85
403
418
  ):
404
419
  return _follow_pose_error("pitch_center_deg must be an integer in 5..85")
405
420
 
406
- downsample_hz = arguments.get("downsample_hz", 20.0)
407
- if not _is_number_arg(downsample_hz) or not 0 < downsample_hz <= 60:
408
- return _follow_pose_error("downsample_hz must be a number in (0, 60]")
421
+ smoothing_window = (
422
+ arguments["smoothing_window"]
423
+ if "smoothing_window" in arguments
424
+ else resolve_default(tool_name, "smoothing_window", 5)
425
+ )
426
+ if not _is_int_arg(smoothing_window) or not 1 <= smoothing_window <= 20:
427
+ return _follow_pose_error("smoothing_window must be an integer in 1..20")
428
+
429
+ downsample_hz = (
430
+ arguments["downsample_hz"]
431
+ if "downsample_hz" in arguments
432
+ else resolve_default(tool_name, "downsample_hz", 20.0)
433
+ )
434
+ if not _is_number_arg(downsample_hz) or not 0 < downsample_hz <= 20:
435
+ return _follow_pose_error("downsample_hz must be a number in (0, 20]")
409
436
 
410
- max_step_deg = arguments.get("max_step_deg", 12.0)
437
+ max_step_deg = (
438
+ arguments["max_step_deg"]
439
+ if "max_step_deg" in arguments
440
+ else resolve_default(tool_name, "max_step_deg", 12.0)
441
+ )
411
442
  if not _is_number_arg(max_step_deg) or not 0 < max_step_deg <= 30:
412
443
  return _follow_pose_error("max_step_deg must be a number in (0, 30]")
413
444
 
414
- speed_dps = arguments.get("speed_dps", 240)
445
+ speed_dps = (
446
+ arguments["speed_dps"]
447
+ if "speed_dps" in arguments
448
+ else resolve_default(tool_name, "speed_dps", 240)
449
+ )
415
450
  if not _is_int_arg(speed_dps) or not 1 <= speed_dps <= 240:
416
451
  return _follow_pose_error("speed_dps must be an integer in 1..240")
417
452
 
@@ -429,6 +464,7 @@ async def _handle_follow_pose_stream(
429
464
  flip_yaw=flip_yaw,
430
465
  flip_pitch=flip_pitch,
431
466
  pitch_center_deg=pitch_center_deg,
467
+ smoothing_window=smoothing_window,
432
468
  downsample_hz=float(downsample_hz),
433
469
  max_step_deg=float(max_step_deg),
434
470
  speed_dps=speed_dps,
@@ -610,6 +646,14 @@ async def _dispatch_mcp_tool(
610
646
  "self.gateway_config.set",
611
647
  arguments,
612
648
  ),
649
+ "get_touch_sensor_enabled": (
650
+ "self.robot.get_touch_sensor_enabled",
651
+ {},
652
+ ),
653
+ "set_touch_sensor_enabled": (
654
+ "self.robot.set_touch_sensor_enabled",
655
+ arguments,
656
+ ),
613
657
  "set_avatar": (
614
658
  "self.display.set_avatar",
615
659
  arguments,
@@ -927,15 +971,29 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
927
971
  "anchor. Defaults to the head's neutral pose."
928
972
  ),
929
973
  },
974
+ "smoothing_window": {
975
+ "type": "integer",
976
+ "default": 5,
977
+ "minimum": 1,
978
+ "maximum": 20,
979
+ "description": (
980
+ "Moving-average window size for incoming sensor "
981
+ "frames. 1 = passthrough (disable gateway-side "
982
+ "smoothing). Default 5."
983
+ ),
984
+ },
930
985
  "downsample_hz": {
931
986
  "type": "number",
932
987
  "default": 20,
933
988
  "exclusiveMinimum": 0,
934
- "maximum": 60,
989
+ "maximum": 20,
935
990
  "description": (
936
991
  "Cap servo command rate. Recent frames are "
937
992
  "smoothed; commands are issued at most this "
938
- "frequently."
993
+ "frequently. Capped at 20 to match the "
994
+ "SCS0009 servo's observed sustained WritePos "
995
+ "rate; higher continuous rates can trigger "
996
+ "UART hang."
939
997
  ),
940
998
  },
941
999
  "max_step_deg": {
@@ -1062,6 +1120,38 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1062
1120
  },
1063
1121
  },
1064
1122
  ),
1123
+ Tool(
1124
+ name="get_touch_sensor_enabled",
1125
+ description=(
1126
+ "Read the device's NVS-backed head-touch sensor enable "
1127
+ "flag. When disabled, the firmware stops both the local "
1128
+ "motion response and the MCP stackchan/event emission; "
1129
+ "the setting persists across reboot."
1130
+ ),
1131
+ inputSchema={"type": "object", "properties": {}},
1132
+ ),
1133
+ Tool(
1134
+ name="set_touch_sensor_enabled",
1135
+ description=(
1136
+ "Enable or disable the device's head-touch sensor at "
1137
+ "runtime. The NVS-backed setting persists across reboot. "
1138
+ "Disabling stops both the firmware local motion response "
1139
+ "and the MCP stackchan/event emission."
1140
+ ),
1141
+ inputSchema={
1142
+ "type": "object",
1143
+ "properties": {
1144
+ "enabled": {
1145
+ "type": "boolean",
1146
+ "description": (
1147
+ "True to enable tap/stroke detection; false "
1148
+ "to disable local reactions and event emission."
1149
+ ),
1150
+ },
1151
+ },
1152
+ "required": ["enabled"],
1153
+ },
1154
+ ),
1065
1155
  Tool(
1066
1156
  name="set_avatar",
1067
1157
  description=(
@@ -1516,10 +1606,22 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1516
1606
  "speaker_id": {
1517
1607
  "type": "integer",
1518
1608
  "description": (
1519
- "Engine-specific speaker identifier "
1609
+ "Engine-specific numeric speaker identifier "
1520
1610
  "(e.g. a VOICEVOX speaker ID)."
1521
1611
  ),
1522
1612
  },
1613
+ "speaker_name": {
1614
+ "type": "string",
1615
+ "description": (
1616
+ "Engine-specific string speaker/voice "
1617
+ "identifier (e.g. an Edge TTS voice name "
1618
+ "such as 'en-US-AriaNeural'). Distinct from "
1619
+ "'voice', which selects the engine itself "
1620
+ "(e.g. 'edge-tts'); use speaker_name for "
1621
+ "engines whose speaker selector is a string "
1622
+ "name rather than the numeric speaker_id."
1623
+ ),
1624
+ },
1523
1625
  "reference_audio": {
1524
1626
  "type": "string",
1525
1627
  "description": (
@@ -1644,7 +1746,12 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1644
1746
  "address, then read' patterns, use `i2c_write_read` "
1645
1747
  "instead. Returns "
1646
1748
  "{\"ok\": true, \"bytes\": [...]} or "
1647
- "{\"ok\": false, \"error\": \"ESP_ERR_TIMEOUT\"} on NACK."
1749
+ "{\"ok\": false, \"error\": \"ESP_ERR_TIMEOUT\"} on NACK. "
1750
+ "Optional `scl_speed_hz` (default 400000) sets the I2C "
1751
+ "clock for this transaction; lower it (e.g. 100000 or "
1752
+ "200000) for slower Units such as the RCWL-9620 "
1753
+ "ultrasonic ranger that fail at 400 kHz with "
1754
+ "ESP_ERR_INVALID_STATE."
1648
1755
  ),
1649
1756
  inputSchema={
1650
1757
  "type": "object",
@@ -1665,6 +1772,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1665
1772
  "minimum": 1,
1666
1773
  "maximum": 256,
1667
1774
  },
1775
+ "scl_speed_hz": {
1776
+ "type": "integer",
1777
+ "default": 400000,
1778
+ "description": (
1779
+ "I2C clock for this transaction. Default "
1780
+ "400000; lower it (e.g. 100000 or 200000) "
1781
+ "for slower Units such as the RCWL-9620 "
1782
+ "ultrasonic ranger that fail at 400 kHz "
1783
+ "with ESP_ERR_INVALID_STATE."
1784
+ ),
1785
+ "minimum": 100000,
1786
+ "maximum": 1000000,
1787
+ },
1668
1788
  },
1669
1789
  "required": ["addr", "n_bytes"],
1670
1790
  },
@@ -1676,7 +1796,11 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1676
1796
  "on Grove Port A. `bytes` is an array of integers "
1677
1797
  "(0..255). This tool operates on the external Port A "
1678
1798
  "bus only; on-board ICs (PMIC, AW9523, touch, etc.) "
1679
- "on the internal bus are not reachable."
1799
+ "on the internal bus are not reachable. Optional "
1800
+ "`scl_speed_hz` (default 400000) sets the I2C clock for "
1801
+ "this transaction; lower it (e.g. 100000 or 200000) for "
1802
+ "slower Units such as the RCWL-9620 ultrasonic ranger "
1803
+ "that fail at 400 kHz with ESP_ERR_INVALID_STATE."
1680
1804
  ),
1681
1805
  inputSchema={
1682
1806
  "type": "object",
@@ -1700,6 +1824,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1700
1824
  "maximum": 255,
1701
1825
  },
1702
1826
  },
1827
+ "scl_speed_hz": {
1828
+ "type": "integer",
1829
+ "default": 400000,
1830
+ "description": (
1831
+ "I2C clock for this transaction. Default "
1832
+ "400000; lower it (e.g. 100000 or 200000) "
1833
+ "for slower Units such as the RCWL-9620 "
1834
+ "ultrasonic ranger that fail at 400 kHz "
1835
+ "with ESP_ERR_INVALID_STATE."
1836
+ ),
1837
+ "minimum": 100000,
1838
+ "maximum": 1000000,
1839
+ },
1703
1840
  },
1704
1841
  "required": ["addr", "bytes"],
1705
1842
  },
@@ -1712,7 +1849,11 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1712
1849
  "single Repeated Start transaction. Common 'set "
1713
1850
  "register pointer, then read' idiom: pass "
1714
1851
  "write_bytes=[reg_addr] to read from a specific "
1715
- "register."
1852
+ "register. Optional `scl_speed_hz` (default 400000) "
1853
+ "sets the I2C clock for this transaction; lower it "
1854
+ "(e.g. 100000 or 200000) for slower Units such as the "
1855
+ "RCWL-9620 ultrasonic ranger that fail at 400 kHz with "
1856
+ "ESP_ERR_INVALID_STATE."
1716
1857
  ),
1717
1858
  inputSchema={
1718
1859
  "type": "object",
@@ -1745,6 +1886,19 @@ def create_server(notify_config: NotifyConfig | None = None) -> StackChanServer:
1745
1886
  "minimum": 1,
1746
1887
  "maximum": 256,
1747
1888
  },
1889
+ "scl_speed_hz": {
1890
+ "type": "integer",
1891
+ "default": 400000,
1892
+ "description": (
1893
+ "I2C clock for this transaction. Default "
1894
+ "400000; lower it (e.g. 100000 or 200000) "
1895
+ "for slower Units such as the RCWL-9620 "
1896
+ "ultrasonic ranger that fail at 400 kHz "
1897
+ "with ESP_ERR_INVALID_STATE."
1898
+ ),
1899
+ "minimum": 100000,
1900
+ "maximum": 1000000,
1901
+ },
1748
1902
  },
1749
1903
  "required": ["addr", "write_bytes", "n_bytes"],
1750
1904
  },
@@ -54,8 +54,15 @@ def _register_irodori() -> None:
54
54
  get_registry().register(IrodoriEngine())
55
55
 
56
56
 
57
+ def _register_edge_tts() -> None:
58
+ from .edge_tts import EdgeTTSEngine
59
+
60
+ get_registry().register(EdgeTTSEngine())
61
+
62
+
57
63
  _try_register(_register_voicevox, "voicevox")
58
64
  _try_register(_register_irodori, "irodori")
65
+ _try_register(_register_edge_tts, "edge-tts")
59
66
 
60
67
 
61
68
  __all__ = [
@@ -0,0 +1,158 @@
1
+ """Edge TTS engine — subprocess client for Microsoft's online TTS service.
2
+
3
+ Uses the `edge-tts` Python package's CLI (https://github.com/rany2/edge-tts)
4
+ to synthesise speech via Microsoft's online TTS service. No local model or
5
+ Docker container needed — just `pip install edge-tts` and the `edge-tts`
6
+ binary on PATH. Produces natural English (and many other language) voices,
7
+ unlike VOICEVOX which is Japanese-only by default.
8
+
9
+ edge-tts always emits MP3 regardless of the output filename/extension
10
+ passed to --write-media, so this engine pipes that MP3 through ffmpeg to
11
+ get raw 16 kHz mono PCM.
12
+
13
+ This is intentionally a subprocess-based engine (not an HTTP client like
14
+ VoicevoxEngine) since edge-tts ships as a CLI tool, not a long-running
15
+ server.
16
+
17
+ Configuration (environment variables):
18
+
19
+ STACKCHAN_EDGE_TTS_DEFAULT_VOICE
20
+ Voice name used when the say() tool does not specify one via
21
+ speaker_name. Default "en-GB-SoniaNeural". Read lazily on each
22
+ access (not cached at import/construction time) so a .env file
23
+ loaded after engine registration — e.g. under
24
+ `serve --transport streamable-http`, where the tts package import
25
+ chain resolves before _configure_gateway_startup() loads .env —
26
+ still takes effect.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ import os
34
+ import tempfile
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from .audio_utils import DEVICE_SAMPLE_RATE
39
+ from .base import TTSEngine
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ #: Default voice. en-GB-SoniaNeural is a natural British English voice.
45
+ DEFAULT_EDGE_TTS_VOICE = "en-GB-SoniaNeural"
46
+
47
+ #: Timeout for the edge-tts subprocess and the ffmpeg conversion step.
48
+ DEFAULT_SUBPROCESS_TIMEOUT_SECONDS = 30.0
49
+
50
+
51
+ class EdgeTTSEngine(TTSEngine):
52
+ """Synthesise text via the edge-tts CLI, return 16 kHz mono PCM.
53
+
54
+ Setup: `pip install edge-tts` (already on PATH as `edge-tts` once
55
+ installed) and `ffmpeg` on PATH for MP3 -> PCM conversion.
56
+ """
57
+
58
+ name = "edge-tts"
59
+
60
+ def __init__(
61
+ self,
62
+ default_voice: str | None = None,
63
+ timeout_seconds: float = DEFAULT_SUBPROCESS_TIMEOUT_SECONDS,
64
+ edge_tts_binary: str | None = None,
65
+ ffmpeg_binary: str | None = None,
66
+ ) -> None:
67
+ # Only the explicit constructor override is stored here. The
68
+ # STACKCHAN_EDGE_TTS_DEFAULT_VOICE environment variable is
69
+ # intentionally NOT read in __init__ — see default_voice below
70
+ # and the module docstring for why.
71
+ self._default_voice_override = default_voice
72
+ self._timeout_seconds = timeout_seconds
73
+ self._edge_tts_binary = edge_tts_binary or "edge-tts"
74
+ self._ffmpeg_binary = ffmpeg_binary or "ffmpeg"
75
+
76
+ @property
77
+ def default_voice(self) -> str:
78
+ """Voice name used when no voice is specified per-call.
79
+
80
+ Resolved lazily on every access: explicit constructor override
81
+ wins, otherwise STACKCHAN_EDGE_TTS_DEFAULT_VOICE is read fresh
82
+ from the environment, falling back to DEFAULT_EDGE_TTS_VOICE.
83
+ """
84
+ if self._default_voice_override is not None:
85
+ return self._default_voice_override
86
+ return os.getenv("STACKCHAN_EDGE_TTS_DEFAULT_VOICE") or DEFAULT_EDGE_TTS_VOICE
87
+
88
+ async def synthesize(self, text: str, **opts: Any) -> bytes:
89
+ """Run edge-tts + ffmpeg, return 16 kHz mono signed-16-bit PCM.
90
+
91
+ Recognised opts:
92
+
93
+ speaker_name: str
94
+ Edge TTS voice name (e.g. "en-GB-SoniaNeural",
95
+ "en-US-AriaNeural"). Falls back to `default_voice`. This
96
+ is distinct from the say() tool's `voice` argument, which
97
+ selects the engine itself (e.g. "edge-tts"); speaker_name
98
+ is the string equivalent of VOICEVOX's numeric
99
+ speaker_id, for engines whose speaker selector is a name.
100
+ speaker_id: int | None
101
+ Ignored. Accepted only so the orchestrator can pass a
102
+ uniform argument set across engines without raising.
103
+ """
104
+ if not text:
105
+ raise ValueError("text must not be empty")
106
+
107
+ speaker_name = opts.get("speaker_name")
108
+ voice = speaker_name if isinstance(speaker_name, str) and speaker_name else self.default_voice
109
+
110
+ with tempfile.TemporaryDirectory() as tmpdir:
111
+ mp3_path = Path(tmpdir) / "out.mp3"
112
+ pcm_path = Path(tmpdir) / "out.pcm"
113
+
114
+ proc = await asyncio.create_subprocess_exec(
115
+ self._edge_tts_binary,
116
+ "--voice", voice,
117
+ "--text", text,
118
+ "--write-media", str(mp3_path),
119
+ stdin=asyncio.subprocess.DEVNULL,
120
+ stdout=asyncio.subprocess.PIPE,
121
+ stderr=asyncio.subprocess.PIPE,
122
+ )
123
+ _, stderr = await asyncio.wait_for(
124
+ proc.communicate(), timeout=self._timeout_seconds
125
+ )
126
+ if proc.returncode != 0:
127
+ raise RuntimeError(
128
+ f"edge-tts failed (code {proc.returncode}): "
129
+ f"{stderr.decode(errors='replace')}"
130
+ )
131
+
132
+ ffmpeg_proc = await asyncio.create_subprocess_exec(
133
+ self._ffmpeg_binary, "-nostdin", "-y", "-i", str(mp3_path),
134
+ "-f", "s16le", "-ar", str(DEVICE_SAMPLE_RATE), "-ac", "1",
135
+ str(pcm_path),
136
+ stdin=asyncio.subprocess.DEVNULL,
137
+ stdout=asyncio.subprocess.PIPE,
138
+ stderr=asyncio.subprocess.PIPE,
139
+ )
140
+ _, ffmpeg_stderr = await asyncio.wait_for(
141
+ ffmpeg_proc.communicate(), timeout=self._timeout_seconds
142
+ )
143
+ if ffmpeg_proc.returncode != 0:
144
+ raise RuntimeError(
145
+ f"ffmpeg conversion failed (code {ffmpeg_proc.returncode}): "
146
+ f"{ffmpeg_stderr.decode(errors='replace')}"
147
+ )
148
+
149
+ pcm = pcm_path.read_bytes()
150
+
151
+ logger.info(
152
+ "edge-tts synthesised %d bytes PCM (16 kHz mono) for "
153
+ "voice=%s, text=%r",
154
+ len(pcm),
155
+ voice,
156
+ text[:60],
157
+ )
158
+ return pcm
@@ -238,6 +238,7 @@ async def synthesize_and_send(
238
238
  )
239
239
 
240
240
  speaker_id = arguments.get("speaker_id")
241
+ speaker_name = arguments.get("speaker_name")
241
242
  reference_audio = arguments.get("reference_audio")
242
243
 
243
244
  tts_text = text
@@ -308,6 +309,7 @@ async def synthesize_and_send(
308
309
  pcm = await engine.synthesize(
309
310
  tts_text,
310
311
  speaker_id=speaker_id,
312
+ speaker_name=speaker_name,
311
313
  reference_audio=reference_audio,
312
314
  )
313
315
  except ValueError: