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.
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/PKG-INFO +3 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/pyproject.toml +3 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/cli.py +4 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stdio_server.py +168 -14
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/__init__.py +7 -0
- stackchan_mcp-0.13.0/stackchan_mcp/tts/edge_tts.py +158 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/orchestrator.py +2 -0
- stackchan_mcp-0.13.0/stackchan_mcp/user_defaults.py +254 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stdio_server.py +368 -0
- stackchan_mcp-0.13.0/tests/test_user_defaults.py +99 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/uv.lock +14 -1
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/.env.example +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/.gitignore +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/AGENTS.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/LICENSE +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/LICENSE-THIRD-PARTY +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/README.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/__main__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/_libs/SOURCES.md +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_input_hook.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/audio_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/capture_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/esp32_client.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/event_log.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/follow_pose_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/gateway.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/audio.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/camera.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/handlers/robot.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/http_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mcp_router.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/mdns_advertiser.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify.example.yml +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/notify_config.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/ownership.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/protocol.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/queue.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/__init__.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/base.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/faster_whisper.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/openai_whisper.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/stt/orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tools.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/base.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/emoji_expression.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/irodori.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/stackchan_mcp/tts/voicevox.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/_audio_fixtures.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/conftest.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_input_hook.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_capture_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_cli.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_emoji_expression.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_esp32_client.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_event_dispatch.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_event_log.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_follow_pose_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_gateway.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_http_server.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_irodori.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_mcp_router.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_mdns_advertiser.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_notify_config.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_ownership.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_protocol.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_audio.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_send_pcm_stream.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stackchan_event.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_audio_utils.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_framework.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_stt_orchestrator.py +0 -0
- {stackchan_mcp-0.12.0 → stackchan_mcp-0.13.0}/tests/test_tts_framework.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 =
|
|
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 =
|
|
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":
|
|
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:
|