@oneciel-ai/ciel-runtime 0.2.3 → 0.2.5
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.
- package/ciel_runtime.py +4 -2
- package/ciel_runtime_support/config_repository.py +32 -0
- package/ciel_runtime_support/router_http.py +6 -0
- package/ciel_runtime_support/router_server_context.py +2 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/speech_http_controller.py +358 -0
- package/ciel_runtime_support/web_ui.py +280 -1
- package/docs/COLAB_SPEECH.md +34 -0
- package/package.json +4 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
- package/scripts/colab/bootstrap_moss_tts.py +150 -0
- package/scripts/colab/bootstrap_qwen_asr.py +116 -0
- package/scripts/configure_speech_workers.py +43 -0
- package/scripts/deploy_colab_speech.ps1 +56 -0
package/ciel_runtime.py
CHANGED
|
@@ -500,6 +500,7 @@ from ciel_runtime_support.runtime_paths import (CHANNEL_COMPACT_REQUEST_PATH, #
|
|
|
500
500
|
from ciel_runtime_support.runtime_restart import forced_upgrade_environment
|
|
501
501
|
from ciel_runtime_support.runtime_restart import running_from_npm_package as detect_running_from_npm_package
|
|
502
502
|
from ciel_runtime_support.secure_json_repository import SecureJsonEffects, SecureJsonRepository
|
|
503
|
+
from ciel_runtime_support.speech_http_controller import SpeechHttpController, SpeechHttpPorts
|
|
503
504
|
from ciel_runtime_support.session_import import ImportSessionHttpController, ImportSessionHttpPorts, ImportSessionLimits, ImportSessionRepository, ImportSessionService, import_record_line, import_tool_text, normalize_import_source
|
|
504
505
|
from ciel_runtime_support.slash_command_assets import ADVISOR_NATIVE_DISABLED_SLASH_COMMAND # noqa: F401 - compatibility export
|
|
505
506
|
from ciel_runtime_support.slash_command_assets import LEGACY_ADVISOR_CALL_MARKER # noqa: F401 - compatibility export
|
|
@@ -1700,6 +1701,7 @@ def render_router_home_html(cfg: dict[str, Any], provider: str, pcfg: dict[str,
|
|
|
1700
1701
|
def render_web_chat_html(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any]) -> str: return web_ui_controller().render_web_chat(cfg, provider, pcfg)
|
|
1701
1702
|
def handle_web_get(handler: BaseHTTPRequestHandler, path: str) -> bool: return web_ui_controller().handle_get(handler, path)
|
|
1702
1703
|
|
|
1704
|
+
def speech_http_controller() -> SpeechHttpController: return SpeechHttpController(SpeechHttpPorts(load_config, save_config, write_json, router_log))
|
|
1703
1705
|
def parse_json_body(raw: bytes) -> dict[str, Any]:
|
|
1704
1706
|
try:
|
|
1705
1707
|
value = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
@@ -2817,8 +2819,8 @@ def _router_server_context() -> RouterServerContext:
|
|
|
2817
2819
|
http_services = RouterHttpServices(
|
|
2818
2820
|
core=RouterHttpCore(load_config, reject_external_router_request, get_current_provider, parse_json_body, is_client_disconnect_error, router_log),
|
|
2819
2821
|
get=RouterHttpGetEndpoints(handle_codex_mcp_split_proxy_get, handle_events_get, handle_llm_config_get, handle_channel_mcp_get, handle_web_get,
|
|
2820
|
-
handle_chat_get, handle_plan_get, route_runtime_get),
|
|
2821
|
-
post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
|
|
2822
|
+
lambda handler, path: speech_http_controller().get(handler, path), handle_chat_get, handle_plan_get, route_runtime_get),
|
|
2823
|
+
post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, lambda handler, path, raw, content_type: speech_http_controller().post(handler, path, raw, content_type), handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
|
|
2822
2824
|
handle_plan_post, route_runtime_post),
|
|
2823
2825
|
presentation=RouterHttpPresentation(render_router_home_html, router_health_payload, write_text_response, write_json, list_model_objects_for_request,
|
|
2824
2826
|
resolve_requested_model, model_object),
|
|
@@ -26,6 +26,38 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
26
26
|
"port": 0,
|
|
27
27
|
"tailscale_https": False,
|
|
28
28
|
},
|
|
29
|
+
"speech": {
|
|
30
|
+
"asr": {
|
|
31
|
+
"enabled": False,
|
|
32
|
+
"base_url": "http://ciel-asr:8000",
|
|
33
|
+
"endpoint": "/v1/audio/transcriptions",
|
|
34
|
+
"model": "Qwen/Qwen3-ASR-0.6B",
|
|
35
|
+
"language": "auto",
|
|
36
|
+
"api_key": "",
|
|
37
|
+
"timeout_seconds": 300,
|
|
38
|
+
},
|
|
39
|
+
"tts": {
|
|
40
|
+
"enabled": False,
|
|
41
|
+
"base_url": "http://ciel-tts:8091",
|
|
42
|
+
"endpoint": "/v1/audio/speech",
|
|
43
|
+
"voices_endpoint": "/v1/audio/voices",
|
|
44
|
+
"model": "OpenMOSS-Team/MOSS-TTS-Nano",
|
|
45
|
+
"voice": "default",
|
|
46
|
+
"language": "ko",
|
|
47
|
+
"ref_audio": "",
|
|
48
|
+
"ref_text": "",
|
|
49
|
+
"response_format": "wav",
|
|
50
|
+
"speed": 1.0,
|
|
51
|
+
"auto_speak": False,
|
|
52
|
+
"api_key": "",
|
|
53
|
+
"timeout_seconds": 300,
|
|
54
|
+
},
|
|
55
|
+
"tailscale": {
|
|
56
|
+
"enabled": True,
|
|
57
|
+
"asr_hostname": "ciel-asr",
|
|
58
|
+
"tts_hostname": "ciel-tts",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
29
61
|
"claude_code": {
|
|
30
62
|
"compat_prompt_for_non_anthropic": True,
|
|
31
63
|
"channels": [],
|
|
@@ -90,6 +90,7 @@ class RouterHttpGetEndpoints:
|
|
|
90
90
|
llm_config: Callable[[Any, str], bool]
|
|
91
91
|
channel_mcp: Callable[[Any, str], bool]
|
|
92
92
|
web: Callable[[Any, str], bool]
|
|
93
|
+
speech: Callable[[Any, str], bool]
|
|
93
94
|
chat: Callable[[Any, str], bool]
|
|
94
95
|
plan: Callable[[Any, str], bool]
|
|
95
96
|
runtime: Callable[..., bool]
|
|
@@ -98,6 +99,7 @@ class RouterHttpGetEndpoints:
|
|
|
98
99
|
@dataclass(frozen=True, slots=True)
|
|
99
100
|
class RouterHttpPostEndpoints:
|
|
100
101
|
codex_mcp_split: Callable[[Any, str, bytes, str], bool]
|
|
102
|
+
speech: Callable[[Any, str, bytes, str], bool]
|
|
101
103
|
llm_config: Callable[[Any, str, dict[str, Any]], bool]
|
|
102
104
|
channel_mcp: Callable[[Any, str, dict[str, Any]], bool]
|
|
103
105
|
chat: Callable[[Any, str, dict[str, Any]], bool]
|
|
@@ -636,6 +638,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
|
|
|
636
638
|
return
|
|
637
639
|
if endpoints.web(self, path):
|
|
638
640
|
return
|
|
641
|
+
if endpoints.speech(self, path):
|
|
642
|
+
return
|
|
639
643
|
if endpoints.chat(self, path) or endpoints.plan(self, path):
|
|
640
644
|
return
|
|
641
645
|
provider, pcfg = services.core.get_current_provider(cfg)
|
|
@@ -676,6 +680,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
|
|
|
676
680
|
endpoints = services.post
|
|
677
681
|
if endpoints.codex_mcp_split(self, path, raw, "POST"):
|
|
678
682
|
return
|
|
683
|
+
if endpoints.speech(self, path, raw, str(self.headers.get("content-type") or "application/json")):
|
|
684
|
+
return
|
|
679
685
|
body = services.core.parse_json_body(raw)
|
|
680
686
|
if endpoints.llm_config(self, path, body):
|
|
681
687
|
return
|
|
@@ -49,6 +49,8 @@ class RouterServerContext:
|
|
|
49
49
|
"provider": provider,
|
|
50
50
|
"model": self.health.current_alias(cfg),
|
|
51
51
|
"web_chat": "/ca/web/chat",
|
|
52
|
+
"web_chat_api": "/ca/web/chat/api",
|
|
53
|
+
"speech": "/ca/speech/health",
|
|
52
54
|
"chat": "/ca/chat/health",
|
|
53
55
|
"plan": "/ca/plan/artifacts",
|
|
54
56
|
"events": "/ca/events",
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Speech configuration and OpenAI-compatible ASR/TTS proxy endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from http.server import BaseHTTPRequestHandler
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SpeechConfig = dict[str, Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SpeechHttpPorts:
|
|
21
|
+
load_config: Callable[[], dict[str, Any]]
|
|
22
|
+
save_config: Callable[[dict[str, Any]], None]
|
|
23
|
+
write_json: Callable[..., None]
|
|
24
|
+
log: Callable[[str, str], None]
|
|
25
|
+
urlopen: Callable[..., Any] = urllib.request.urlopen
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class SpeechHttpController:
|
|
30
|
+
ports: SpeechHttpPorts
|
|
31
|
+
|
|
32
|
+
def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
|
|
33
|
+
if path == "/ca/speech/config":
|
|
34
|
+
self.ports.write_json(handler, self.public_config())
|
|
35
|
+
return True
|
|
36
|
+
if path == "/ca/speech/health":
|
|
37
|
+
self.ports.write_json(handler, self.health_payload())
|
|
38
|
+
return True
|
|
39
|
+
if path == "/ca/web/chat/api":
|
|
40
|
+
self.ports.write_json(handler, self.discovery_payload())
|
|
41
|
+
return True
|
|
42
|
+
if path == "/v1/audio/voices":
|
|
43
|
+
return self._proxy_get(handler, "tts", "voices_endpoint")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def post(
|
|
47
|
+
self,
|
|
48
|
+
handler: BaseHTTPRequestHandler,
|
|
49
|
+
path: str,
|
|
50
|
+
raw: bytes,
|
|
51
|
+
content_type: str,
|
|
52
|
+
) -> bool:
|
|
53
|
+
if path == "/ca/speech/config":
|
|
54
|
+
return self._save_public_config(handler, raw)
|
|
55
|
+
if path in {"/v1/audio/transcriptions", "/v1/audio/translations"}:
|
|
56
|
+
return self._proxy_asr(handler, raw, content_type)
|
|
57
|
+
if path in {"/v1/audio/speech", "/v1/audio/speech/batch"}:
|
|
58
|
+
return self._proxy_tts(handler, raw, content_type, batch=path.endswith("/batch"))
|
|
59
|
+
if path == "/v1/audio/voices":
|
|
60
|
+
return self._proxy_raw(handler, "tts", "voices_endpoint", raw, content_type)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def public_config(self) -> dict[str, Any]:
|
|
64
|
+
speech = self._speech_config()
|
|
65
|
+
public: dict[str, Any] = {"ok": True}
|
|
66
|
+
for name in ("asr", "tts"):
|
|
67
|
+
source = speech.get(name) if isinstance(speech.get(name), dict) else {}
|
|
68
|
+
item = {key: value for key, value in source.items() if key not in {"api_key", "ref_audio"}}
|
|
69
|
+
item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
|
|
70
|
+
if name == "tts":
|
|
71
|
+
item["ref_audio_set"] = bool(str(source.get("ref_audio") or "").strip())
|
|
72
|
+
public[name] = item
|
|
73
|
+
tailscale = speech.get("tailscale")
|
|
74
|
+
public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
|
|
75
|
+
public["endpoints"] = self.discovery_payload()["endpoints"]
|
|
76
|
+
return public
|
|
77
|
+
|
|
78
|
+
def discovery_payload(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"ok": True,
|
|
81
|
+
"web_chat": "/ca/web/chat",
|
|
82
|
+
"endpoints": {
|
|
83
|
+
"chat_health": "GET /ca/channel/health",
|
|
84
|
+
"chat_messages": "GET|POST /ca/channel/messages",
|
|
85
|
+
"chat_wait": "GET /ca/channel/wait",
|
|
86
|
+
"chat_stream": "GET /ca/channel/stream",
|
|
87
|
+
"chat_files": "POST /ca/channel/files",
|
|
88
|
+
"speech_config": "GET|POST /ca/speech/config",
|
|
89
|
+
"speech_health": "GET /ca/speech/health",
|
|
90
|
+
"asr": "POST /v1/audio/transcriptions",
|
|
91
|
+
"asr_translate": "POST /v1/audio/translations",
|
|
92
|
+
"tts": "POST /v1/audio/speech",
|
|
93
|
+
"tts_batch": "POST /v1/audio/speech/batch",
|
|
94
|
+
"tts_voices": "GET|POST /v1/audio/voices",
|
|
95
|
+
"models": "GET /v1/models",
|
|
96
|
+
"responses": "POST /v1/responses",
|
|
97
|
+
"messages": "POST /v1/messages",
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def health_payload(self) -> dict[str, Any]:
|
|
102
|
+
services = {name: self._probe(name) for name in ("asr", "tts")}
|
|
103
|
+
return {"ok": all(not item["enabled"] or item["reachable"] for item in services.values()), "services": services}
|
|
104
|
+
|
|
105
|
+
def _speech_config(self) -> SpeechConfig:
|
|
106
|
+
speech = self.ports.load_config().get("speech")
|
|
107
|
+
return speech if isinstance(speech, dict) else {}
|
|
108
|
+
|
|
109
|
+
def _service_config(self, name: str) -> SpeechConfig:
|
|
110
|
+
service = self._speech_config().get(name)
|
|
111
|
+
return service if isinstance(service, dict) else {}
|
|
112
|
+
|
|
113
|
+
def _save_public_config(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
update = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
116
|
+
if not isinstance(update, dict):
|
|
117
|
+
raise ValueError("configuration body must be a JSON object")
|
|
118
|
+
config = self.ports.load_config()
|
|
119
|
+
speech = config.setdefault("speech", {})
|
|
120
|
+
if not isinstance(speech, dict):
|
|
121
|
+
speech = {}
|
|
122
|
+
config["speech"] = speech
|
|
123
|
+
for name in ("asr", "tts"):
|
|
124
|
+
incoming = update.get(name)
|
|
125
|
+
if not isinstance(incoming, dict):
|
|
126
|
+
continue
|
|
127
|
+
current = speech.setdefault(name, {})
|
|
128
|
+
if not isinstance(current, dict):
|
|
129
|
+
current = {}
|
|
130
|
+
speech[name] = current
|
|
131
|
+
for key, value in incoming.items():
|
|
132
|
+
if key in {"api_key_set", "clear_api_key", "ref_audio_set", "clear_ref_audio"}:
|
|
133
|
+
continue
|
|
134
|
+
if key in {"api_key", "ref_audio"} and not str(value or "").strip():
|
|
135
|
+
continue
|
|
136
|
+
current[key] = self._validated_value(name, key, value)
|
|
137
|
+
if incoming.get("clear_api_key") is True:
|
|
138
|
+
current["api_key"] = ""
|
|
139
|
+
if name == "tts" and incoming.get("clear_ref_audio") is True:
|
|
140
|
+
current["ref_audio"] = ""
|
|
141
|
+
tailscale = update.get("tailscale")
|
|
142
|
+
if isinstance(tailscale, dict):
|
|
143
|
+
current_tailscale = speech.setdefault("tailscale", {})
|
|
144
|
+
if not isinstance(current_tailscale, dict):
|
|
145
|
+
current_tailscale = {}
|
|
146
|
+
speech["tailscale"] = current_tailscale
|
|
147
|
+
for key in ("enabled", "asr_hostname", "tts_hostname"):
|
|
148
|
+
if key in tailscale:
|
|
149
|
+
current_tailscale[key] = tailscale[key]
|
|
150
|
+
self.ports.save_config(config)
|
|
151
|
+
self.ports.write_json(handler, self.public_config())
|
|
152
|
+
except (UnicodeError, ValueError, TypeError) as exc:
|
|
153
|
+
self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _validated_value(service: str, key: str, value: Any) -> Any:
|
|
158
|
+
allowed = {
|
|
159
|
+
"asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
|
|
160
|
+
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
|
|
161
|
+
}
|
|
162
|
+
if key not in allowed[service]:
|
|
163
|
+
raise ValueError(f"unsupported {service} setting: {key}")
|
|
164
|
+
if key in {"enabled", "auto_speak"}:
|
|
165
|
+
return bool(value)
|
|
166
|
+
if key == "timeout_seconds":
|
|
167
|
+
return max(1, min(3600, int(value)))
|
|
168
|
+
if key == "speed":
|
|
169
|
+
return max(0.25, min(4.0, float(value)))
|
|
170
|
+
text = str(value or "").strip()
|
|
171
|
+
if key == "ref_audio":
|
|
172
|
+
if len(text) > 14_000_000:
|
|
173
|
+
raise ValueError("TTS reference audio must be 10 MB or smaller")
|
|
174
|
+
if text.startswith("data:audio/") and ";base64," in text:
|
|
175
|
+
try:
|
|
176
|
+
audio = base64.b64decode(text.split(",", 1)[1], validate=True)
|
|
177
|
+
except (ValueError, TypeError) as exc:
|
|
178
|
+
raise ValueError("invalid base64 TTS reference audio") from exc
|
|
179
|
+
if not audio or len(audio) > 10 * 1024 * 1024:
|
|
180
|
+
raise ValueError("TTS reference audio must be between 1 byte and 10 MB")
|
|
181
|
+
return text
|
|
182
|
+
parsed_ref = urllib.parse.urlparse(text)
|
|
183
|
+
if parsed_ref.scheme not in {"http", "https"} or not parsed_ref.netloc:
|
|
184
|
+
raise ValueError("TTS ref_audio must be an audio data URL or HTTP(S) URL")
|
|
185
|
+
return text
|
|
186
|
+
if key == "base_url":
|
|
187
|
+
parsed = urllib.parse.urlparse(text)
|
|
188
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
189
|
+
raise ValueError(f"invalid {service} base_url")
|
|
190
|
+
return text.rstrip("/")
|
|
191
|
+
if key in {"endpoint", "voices_endpoint"} and not text.startswith("/"):
|
|
192
|
+
raise ValueError(f"{service} {key} must begin with /")
|
|
193
|
+
return text
|
|
194
|
+
|
|
195
|
+
def _probe(self, name: str) -> dict[str, Any]:
|
|
196
|
+
config = self._service_config(name)
|
|
197
|
+
enabled = bool(config.get("enabled"))
|
|
198
|
+
result: dict[str, Any] = {
|
|
199
|
+
"enabled": enabled,
|
|
200
|
+
"configured": bool(str(config.get("base_url") or "").strip()),
|
|
201
|
+
"reachable": False,
|
|
202
|
+
}
|
|
203
|
+
if not enabled:
|
|
204
|
+
return result
|
|
205
|
+
try:
|
|
206
|
+
request = urllib.request.Request(self._url(config, "/health"), headers=self._headers(config, "application/json"))
|
|
207
|
+
with self.ports.urlopen(request, timeout=min(10.0, self._timeout(config))) as response:
|
|
208
|
+
result["status"] = int(getattr(response, "status", 200))
|
|
209
|
+
result["reachable"] = result["status"] < 500
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
result["error"] = f"{type(exc).__name__}: {exc}"
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
def _proxy_get(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str) -> bool:
|
|
215
|
+
config = self._service_config(service)
|
|
216
|
+
if not self._require_enabled(handler, service, config):
|
|
217
|
+
return True
|
|
218
|
+
request = urllib.request.Request(self._url(config, str(config.get(endpoint_key) or "")), headers=self._headers(config, "application/json"))
|
|
219
|
+
return self._open_and_write(handler, service, config, request)
|
|
220
|
+
|
|
221
|
+
def _proxy_asr(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str) -> bool:
|
|
222
|
+
config = self._service_config("asr")
|
|
223
|
+
if not self._require_enabled(handler, "asr", config):
|
|
224
|
+
return True
|
|
225
|
+
if "application/json" in content_type.lower():
|
|
226
|
+
try:
|
|
227
|
+
body = json.loads(raw.decode("utf-8"))
|
|
228
|
+
if not isinstance(body, dict):
|
|
229
|
+
raise ValueError("request must be a JSON object")
|
|
230
|
+
audio = base64.b64decode(str(body.get("audio_base64") or ""), validate=True)
|
|
231
|
+
if not audio:
|
|
232
|
+
raise ValueError("audio_base64 is required")
|
|
233
|
+
fields = {
|
|
234
|
+
"model": str(body.get("model") or config.get("model") or ""),
|
|
235
|
+
"language": str(body.get("language") or config.get("language") or ""),
|
|
236
|
+
"response_format": str(body.get("response_format") or "json"),
|
|
237
|
+
}
|
|
238
|
+
if fields["language"].lower() == "auto":
|
|
239
|
+
fields.pop("language")
|
|
240
|
+
raw, content_type = self._multipart(fields, "file", str(body.get("filename") or "recording.wav"), str(body.get("content_type") or "audio/wav"), audio)
|
|
241
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
242
|
+
self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
|
|
243
|
+
return True
|
|
244
|
+
return self._proxy_raw(handler, "asr", "endpoint", raw, content_type)
|
|
245
|
+
|
|
246
|
+
def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
|
|
247
|
+
config = self._service_config("tts")
|
|
248
|
+
if not self._require_enabled(handler, "tts", config):
|
|
249
|
+
return True
|
|
250
|
+
if "application/json" in content_type.lower():
|
|
251
|
+
try:
|
|
252
|
+
body = json.loads(raw.decode("utf-8") if raw else "{}")
|
|
253
|
+
if not isinstance(body, dict):
|
|
254
|
+
raise ValueError("request must be a JSON object")
|
|
255
|
+
if not batch:
|
|
256
|
+
body.setdefault("model", str(config.get("model") or ""))
|
|
257
|
+
body.setdefault("voice", str(config.get("voice") or "default"))
|
|
258
|
+
body.setdefault("language", str(config.get("language") or "Auto"))
|
|
259
|
+
if str(config.get("ref_audio") or "").strip():
|
|
260
|
+
body.setdefault("ref_audio", str(config["ref_audio"]))
|
|
261
|
+
if str(config.get("ref_text") or "").strip():
|
|
262
|
+
body.setdefault("ref_text", str(config["ref_text"]))
|
|
263
|
+
body.setdefault("response_format", str(config.get("response_format") or "wav"))
|
|
264
|
+
body.setdefault("speed", float(config.get("speed") or 1.0))
|
|
265
|
+
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
266
|
+
except (ValueError, TypeError, UnicodeError) as exc:
|
|
267
|
+
self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
|
|
268
|
+
return True
|
|
269
|
+
endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
|
|
270
|
+
return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
|
|
271
|
+
|
|
272
|
+
def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
|
|
273
|
+
config = self._service_config(service)
|
|
274
|
+
if not self._require_enabled(handler, service, config):
|
|
275
|
+
return True
|
|
276
|
+
return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
|
|
277
|
+
|
|
278
|
+
def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
|
|
279
|
+
request = urllib.request.Request(
|
|
280
|
+
self._url(config, endpoint),
|
|
281
|
+
data=raw,
|
|
282
|
+
headers=self._headers(config, content_type or "application/octet-stream"),
|
|
283
|
+
method="POST",
|
|
284
|
+
)
|
|
285
|
+
return self._open_and_write(handler, service, config, request)
|
|
286
|
+
|
|
287
|
+
def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
|
|
288
|
+
try:
|
|
289
|
+
with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
|
|
290
|
+
self._write_bytes(handler, response.read(), int(getattr(response, "status", 200)), str(response.headers.get("content-type") or "application/octet-stream"))
|
|
291
|
+
except urllib.error.HTTPError as exc:
|
|
292
|
+
self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
self.ports.log("ERROR", f"speech_proxy_failed service={service} error={type(exc).__name__}: {exc}")
|
|
295
|
+
self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _require_enabled(handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig) -> bool:
|
|
300
|
+
if bool(config.get("enabled")) and str(config.get("base_url") or "").strip():
|
|
301
|
+
return True
|
|
302
|
+
body = json.dumps({"error": {"type": "service_disabled", "message": f"{service.upper()} is not configured or enabled"}}).encode()
|
|
303
|
+
handler.send_response(503)
|
|
304
|
+
handler.send_header("content-type", "application/json")
|
|
305
|
+
handler.send_header("content-length", str(len(body)))
|
|
306
|
+
handler.end_headers()
|
|
307
|
+
handler.wfile.write(body)
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _write_bytes(handler: BaseHTTPRequestHandler, data: bytes, status: int, content_type: str) -> None:
|
|
312
|
+
handler.send_response(status)
|
|
313
|
+
handler.send_header("content-type", content_type)
|
|
314
|
+
handler.send_header("content-length", str(len(data)))
|
|
315
|
+
handler.send_header("cache-control", "no-store")
|
|
316
|
+
handler.end_headers()
|
|
317
|
+
handler.wfile.write(data)
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _headers(config: SpeechConfig, content_type: str) -> dict[str, str]:
|
|
321
|
+
headers = {"accept": "*/*", "content-type": content_type}
|
|
322
|
+
api_key = str(config.get("api_key") or "").strip()
|
|
323
|
+
if api_key:
|
|
324
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
325
|
+
return headers
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _url(config: SpeechConfig, endpoint: str) -> str:
|
|
329
|
+
return str(config.get("base_url") or "").rstrip("/") + "/" + endpoint.lstrip("/")
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _timeout(config: SpeechConfig) -> float:
|
|
333
|
+
try:
|
|
334
|
+
return max(1.0, min(3600.0, float(config.get("timeout_seconds") or 300)))
|
|
335
|
+
except (TypeError, ValueError):
|
|
336
|
+
return 300.0
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _multipart(fields: dict[str, str], file_field: str, filename: str, mime: str, data: bytes) -> tuple[bytes, str]:
|
|
340
|
+
boundary = "ciel-" + secrets.token_hex(16)
|
|
341
|
+
chunks: list[bytes] = []
|
|
342
|
+
for name, value in fields.items():
|
|
343
|
+
chunks.extend([
|
|
344
|
+
f"--{boundary}\r\n".encode(),
|
|
345
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
|
346
|
+
str(value).encode("utf-8"), b"\r\n",
|
|
347
|
+
])
|
|
348
|
+
safe_filename = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
349
|
+
chunks.extend([
|
|
350
|
+
f"--{boundary}\r\n".encode(),
|
|
351
|
+
f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode(),
|
|
352
|
+
f"Content-Type: {mime}\r\n\r\n".encode(), data, b"\r\n",
|
|
353
|
+
f"--{boundary}--\r\n".encode(),
|
|
354
|
+
])
|
|
355
|
+
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
__all__ = ["SpeechHttpController", "SpeechHttpPorts"]
|
|
@@ -112,6 +112,22 @@ def render_web_chat_page(
|
|
|
112
112
|
}}
|
|
113
113
|
.attach-button:hover {{ border-color: var(--accent); }}
|
|
114
114
|
.attach-button:disabled {{ opacity: .55; cursor: not-allowed; }}
|
|
115
|
+
.recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
|
|
116
|
+
.message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
|
|
117
|
+
.message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
|
|
118
|
+
dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
|
|
119
|
+
dialog::backdrop {{ background: rgba(0,0,0,.72); }}
|
|
120
|
+
.settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
|
|
121
|
+
.settings-body {{ padding: 16px; display: grid; gap: 16px; }}
|
|
122
|
+
.settings-section {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: grid; gap: 10px; }}
|
|
123
|
+
.settings-section h3 {{ margin: 0; font-size: 14px; }}
|
|
124
|
+
.settings-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }}
|
|
125
|
+
.settings-grid label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; }}
|
|
126
|
+
.settings-grid label.wide {{ grid-column: 1 / -1; }}
|
|
127
|
+
.settings-grid input, .settings-grid select {{ width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #080d14; color: var(--text); padding: 8px; }}
|
|
128
|
+
.check {{ display: flex !important; grid-auto-flow: column; justify-content: start; align-items: center; gap: 7px !important; }}
|
|
129
|
+
.check input {{ width: auto; }}
|
|
130
|
+
.settings-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
|
|
115
131
|
#fileInput {{ display: none; }}
|
|
116
132
|
.attachment-tray {{ display: flex; gap: 7px; flex-wrap: wrap; min-height: 0; }}
|
|
117
133
|
.attachment-chip {{
|
|
@@ -135,6 +151,7 @@ def render_web_chat_page(
|
|
|
135
151
|
.bubble {{ max-width: 94%; }}
|
|
136
152
|
header {{ align-items: flex-start; flex-direction: column; }}
|
|
137
153
|
.pill {{ white-space: normal; }}
|
|
154
|
+
.settings-grid {{ grid-template-columns: 1fr; }}
|
|
138
155
|
}}
|
|
139
156
|
</style>
|
|
140
157
|
</head>
|
|
@@ -154,6 +171,8 @@ def render_web_chat_page(
|
|
|
154
171
|
<a href="/">Router Home</a>
|
|
155
172
|
<a href="/ca/events">Events</a>
|
|
156
173
|
<a href="/health">Health JSON</a>
|
|
174
|
+
<a href="/ca/web/chat/api">Chat API JSON</a>
|
|
175
|
+
<button class="ghost" id="speechSettingsButton" type="button">Speech Settings</button>
|
|
157
176
|
<button class="ghost" id="shareButton" type="button">Copy Chat Link</button>
|
|
158
177
|
<button class="ghost" id="clearButton" type="button">Clear Chat</button>
|
|
159
178
|
</div>
|
|
@@ -173,6 +192,7 @@ def render_web_chat_page(
|
|
|
173
192
|
<button class="primary" id="sendButton" type="submit">Send</button>
|
|
174
193
|
</div>
|
|
175
194
|
<div class="composer-actions">
|
|
195
|
+
<button class="attach-button" id="micButton" type="button">Start voice input</button>
|
|
176
196
|
<button class="attach-button" id="attachButton" type="button">Attach files</button>
|
|
177
197
|
<input id="fileInput" type="file" multiple>
|
|
178
198
|
<div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
|
|
@@ -181,6 +201,48 @@ def render_web_chat_page(
|
|
|
181
201
|
</form>
|
|
182
202
|
</main>
|
|
183
203
|
</div>
|
|
204
|
+
<dialog id="speechSettingsDialog">
|
|
205
|
+
<form id="speechSettingsForm">
|
|
206
|
+
<div class="settings-head"><strong>Speech Settings</strong><button class="ghost" id="speechSettingsClose" type="button">Close</button></div>
|
|
207
|
+
<div class="settings-body">
|
|
208
|
+
<section class="settings-section">
|
|
209
|
+
<h3>STT / Qwen ASR</h3>
|
|
210
|
+
<div class="settings-grid">
|
|
211
|
+
<label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
|
|
212
|
+
<label>Language<input id="asrLanguage" placeholder="auto"></label>
|
|
213
|
+
<label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
|
|
214
|
+
<label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
|
|
215
|
+
<label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
216
|
+
</div>
|
|
217
|
+
</section>
|
|
218
|
+
<section class="settings-section">
|
|
219
|
+
<h3>TTS / MOSS-TTS-Nano</h3>
|
|
220
|
+
<div class="settings-grid">
|
|
221
|
+
<label class="check"><input id="ttsEnabled" type="checkbox"> Enable TTS</label>
|
|
222
|
+
<label class="check"><input id="ttsAutoSpeak" type="checkbox"> Speak replies automatically</label>
|
|
223
|
+
<label class="wide">Tailscale base URL<input id="ttsBaseUrl" placeholder="http://ciel-tts:8091"></label>
|
|
224
|
+
<label>Voice<input id="ttsVoice" placeholder="default"></label>
|
|
225
|
+
<label>Language<input id="ttsLanguage" placeholder="ko"></label>
|
|
226
|
+
<label class="wide">Model<input id="ttsModel" placeholder="OpenMOSS-Team/MOSS-TTS-Nano"></label>
|
|
227
|
+
<label class="wide">Reference voice (required by MOSS-TTS-Nano)<input id="ttsReferenceAudio" type="file" accept="audio/*"><span class="hint" id="ttsReferenceAudioStatus">No reference voice configured</span></label>
|
|
228
|
+
<label class="wide">Reference transcript (optional)<input id="ttsReferenceText" placeholder="Transcript of the reference clip"></label>
|
|
229
|
+
<label class="check wide"><input id="ttsClearReferenceAudio" type="checkbox"> Remove the saved reference voice</label>
|
|
230
|
+
<label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
<section class="settings-section">
|
|
234
|
+
<h3>Tailscale tunnel</h3>
|
|
235
|
+
<div class="settings-grid">
|
|
236
|
+
<label class="check"><input id="tailscaleEnabled" type="checkbox"> Use tailnet-only addresses</label>
|
|
237
|
+
<label>ASR hostname<input id="tailscaleAsrHostname" placeholder="ciel-asr"></label>
|
|
238
|
+
<label>TTS hostname<input id="tailscaleTtsHostname" placeholder="ciel-tts"></label>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
|
|
241
|
+
</section>
|
|
242
|
+
<div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
|
|
243
|
+
</div>
|
|
244
|
+
</form>
|
|
245
|
+
</dialog>
|
|
184
246
|
<script>
|
|
185
247
|
const MODEL = {json.dumps(model)};
|
|
186
248
|
const transcript = document.getElementById('transcript');
|
|
@@ -188,10 +250,16 @@ def render_web_chat_page(
|
|
|
188
250
|
const prompt = document.getElementById('prompt');
|
|
189
251
|
const sendButton = document.getElementById('sendButton');
|
|
190
252
|
const attachButton = document.getElementById('attachButton');
|
|
253
|
+
const micButton = document.getElementById('micButton');
|
|
191
254
|
const fileInput = document.getElementById('fileInput');
|
|
192
255
|
const attachmentTray = document.getElementById('attachmentTray');
|
|
193
256
|
const shareButton = document.getElementById('shareButton');
|
|
194
257
|
const clearButton = document.getElementById('clearButton');
|
|
258
|
+
const speechSettingsButton = document.getElementById('speechSettingsButton');
|
|
259
|
+
const speechSettingsDialog = document.getElementById('speechSettingsDialog');
|
|
260
|
+
const speechSettingsForm = document.getElementById('speechSettingsForm');
|
|
261
|
+
const speechSettingsClose = document.getElementById('speechSettingsClose');
|
|
262
|
+
const speechHealthButton = document.getElementById('speechHealthButton');
|
|
195
263
|
const statePill = document.getElementById('statePill');
|
|
196
264
|
const SESSION_KEY = 'ciel-runtime-web-chat-session';
|
|
197
265
|
const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
|
|
@@ -218,6 +286,11 @@ def render_web_chat_page(
|
|
|
218
286
|
let lastId = Number(localStorage.getItem(scopedLastIdKey) || '0') || 0;
|
|
219
287
|
let eventSource = null;
|
|
220
288
|
let selectedFiles = [];
|
|
289
|
+
let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
|
|
290
|
+
let mediaRecorder = null;
|
|
291
|
+
let mediaStream = null;
|
|
292
|
+
let recordingChunks = [];
|
|
293
|
+
let pendingTtsReferenceAudio = '';
|
|
221
294
|
function setState(text, cls = '') {{
|
|
222
295
|
statePill.textContent = text;
|
|
223
296
|
statePill.className = 'pill ' + cls;
|
|
@@ -401,6 +474,16 @@ def render_web_chat_page(
|
|
|
401
474
|
bubble.innerHTML = renderMarkdown(text);
|
|
402
475
|
}}
|
|
403
476
|
row.appendChild(bubble);
|
|
477
|
+
if (role === 'assistant') {{
|
|
478
|
+
const actions = document.createElement('div');
|
|
479
|
+
actions.className = 'message-actions';
|
|
480
|
+
const speak = document.createElement('button');
|
|
481
|
+
speak.type = 'button';
|
|
482
|
+
speak.textContent = 'Speak';
|
|
483
|
+
speak.addEventListener('click', () => speakText(text));
|
|
484
|
+
actions.appendChild(speak);
|
|
485
|
+
row.appendChild(actions);
|
|
486
|
+
}}
|
|
404
487
|
if (mode === 'prepend') {{
|
|
405
488
|
transcript.insertBefore(row, transcript.firstChild);
|
|
406
489
|
}} else {{
|
|
@@ -424,7 +507,10 @@ def render_web_chat_page(
|
|
|
424
507
|
const text = message.message || '';
|
|
425
508
|
if (!text.trim()) return;
|
|
426
509
|
addBubble(roleForMessage(message), text, mode, message.id);
|
|
427
|
-
if (mode !== 'prepend' && message.sender_id !== 'web-user')
|
|
510
|
+
if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
|
|
511
|
+
setState('reply received', 'ok');
|
|
512
|
+
if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
|
|
513
|
+
}}
|
|
428
514
|
}}
|
|
429
515
|
function formatBytes(bytes) {{
|
|
430
516
|
const value = Number(bytes || 0);
|
|
@@ -471,6 +557,150 @@ def render_web_chat_page(
|
|
|
471
557
|
reader.readAsDataURL(file);
|
|
472
558
|
}});
|
|
473
559
|
}}
|
|
560
|
+
function fileToDataUrl(file) {{
|
|
561
|
+
return new Promise((resolve, reject) => {{
|
|
562
|
+
const reader = new FileReader();
|
|
563
|
+
reader.onload = () => resolve(String(reader.result || ''));
|
|
564
|
+
reader.onerror = () => reject(reader.error || new Error('Could not read file'));
|
|
565
|
+
reader.readAsDataURL(file);
|
|
566
|
+
}});
|
|
567
|
+
}}
|
|
568
|
+
function setSpeechForm(config) {{
|
|
569
|
+
const asr = config.asr || {{}};
|
|
570
|
+
const tts = config.tts || {{}};
|
|
571
|
+
const tailscale = config.tailscale || {{}};
|
|
572
|
+
document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
|
|
573
|
+
document.getElementById('asrBaseUrl').value = asr.base_url || '';
|
|
574
|
+
document.getElementById('asrModel').value = asr.model || '';
|
|
575
|
+
document.getElementById('asrLanguage').value = asr.language || 'auto';
|
|
576
|
+
document.getElementById('asrApiKey').value = '';
|
|
577
|
+
document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
578
|
+
document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
|
|
579
|
+
document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
|
|
580
|
+
document.getElementById('ttsBaseUrl').value = tts.base_url || '';
|
|
581
|
+
document.getElementById('ttsModel').value = tts.model || '';
|
|
582
|
+
document.getElementById('ttsVoice').value = tts.voice || 'default';
|
|
583
|
+
document.getElementById('ttsLanguage').value = tts.language || 'ko';
|
|
584
|
+
document.getElementById('ttsReferenceText').value = tts.ref_text || '';
|
|
585
|
+
document.getElementById('ttsReferenceAudioStatus').textContent = tts.ref_audio_set ? 'Reference voice saved securely on this Ciel router' : 'No reference voice configured';
|
|
586
|
+
document.getElementById('ttsClearReferenceAudio').checked = false;
|
|
587
|
+
document.getElementById('ttsReferenceAudio').value = '';
|
|
588
|
+
pendingTtsReferenceAudio = '';
|
|
589
|
+
document.getElementById('ttsApiKey').value = '';
|
|
590
|
+
document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
591
|
+
document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
|
|
592
|
+
document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
|
|
593
|
+
document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
|
|
594
|
+
micButton.disabled = !asr.enabled;
|
|
595
|
+
micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
|
|
596
|
+
}}
|
|
597
|
+
async function loadSpeechConfig() {{
|
|
598
|
+
const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
|
|
599
|
+
const data = await response.json();
|
|
600
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
601
|
+
speechConfig = data;
|
|
602
|
+
setSpeechForm(data);
|
|
603
|
+
return data;
|
|
604
|
+
}}
|
|
605
|
+
async function saveSpeechConfig() {{
|
|
606
|
+
const payload = {{
|
|
607
|
+
asr: {{
|
|
608
|
+
enabled: document.getElementById('asrEnabled').checked,
|
|
609
|
+
base_url: document.getElementById('asrBaseUrl').value,
|
|
610
|
+
model: document.getElementById('asrModel').value,
|
|
611
|
+
language: document.getElementById('asrLanguage').value,
|
|
612
|
+
api_key: document.getElementById('asrApiKey').value,
|
|
613
|
+
}},
|
|
614
|
+
tts: {{
|
|
615
|
+
enabled: document.getElementById('ttsEnabled').checked,
|
|
616
|
+
auto_speak: document.getElementById('ttsAutoSpeak').checked,
|
|
617
|
+
base_url: document.getElementById('ttsBaseUrl').value,
|
|
618
|
+
model: document.getElementById('ttsModel').value,
|
|
619
|
+
voice: document.getElementById('ttsVoice').value,
|
|
620
|
+
language: document.getElementById('ttsLanguage').value,
|
|
621
|
+
ref_audio: pendingTtsReferenceAudio,
|
|
622
|
+
ref_text: document.getElementById('ttsReferenceText').value,
|
|
623
|
+
clear_ref_audio: document.getElementById('ttsClearReferenceAudio').checked,
|
|
624
|
+
api_key: document.getElementById('ttsApiKey').value,
|
|
625
|
+
}},
|
|
626
|
+
tailscale: {{
|
|
627
|
+
enabled: document.getElementById('tailscaleEnabled').checked,
|
|
628
|
+
asr_hostname: document.getElementById('tailscaleAsrHostname').value,
|
|
629
|
+
tts_hostname: document.getElementById('tailscaleTtsHostname').value,
|
|
630
|
+
}},
|
|
631
|
+
}};
|
|
632
|
+
const response = await fetch('/ca/speech/config', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
|
|
633
|
+
const data = await response.json();
|
|
634
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
635
|
+
speechConfig = data;
|
|
636
|
+
setSpeechForm(data);
|
|
637
|
+
return data;
|
|
638
|
+
}}
|
|
639
|
+
async function speakText(text) {{
|
|
640
|
+
if (!speechConfig.tts || !speechConfig.tts.enabled) {{
|
|
641
|
+
setState('TTS disabled', 'error');
|
|
642
|
+
return;
|
|
643
|
+
}}
|
|
644
|
+
try {{
|
|
645
|
+
setState('generating speech');
|
|
646
|
+
const response = await fetch('/v1/audio/speech', {{
|
|
647
|
+
method: 'POST',
|
|
648
|
+
headers: {{'content-type': 'application/json'}},
|
|
649
|
+
body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}})
|
|
650
|
+
}});
|
|
651
|
+
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
652
|
+
const blob = await response.blob();
|
|
653
|
+
const url = URL.createObjectURL(blob);
|
|
654
|
+
const audio = new Audio(url);
|
|
655
|
+
audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
|
|
656
|
+
audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
|
|
657
|
+
await audio.play();
|
|
658
|
+
setState('speaking', 'ok');
|
|
659
|
+
}} catch (err) {{
|
|
660
|
+
setState('TTS error', 'error');
|
|
661
|
+
addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
|
|
662
|
+
}}
|
|
663
|
+
}}
|
|
664
|
+
async function transcribeRecording(blob) {{
|
|
665
|
+
setState('transcribing');
|
|
666
|
+
const audio_base64 = await fileToBase64(blob);
|
|
667
|
+
const response = await fetch('/v1/audio/transcriptions', {{
|
|
668
|
+
method: 'POST',
|
|
669
|
+
headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
|
|
670
|
+
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
|
|
671
|
+
}});
|
|
672
|
+
const text = await response.text();
|
|
673
|
+
let data = {{}};
|
|
674
|
+
try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{}}
|
|
675
|
+
if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
|
|
676
|
+
const transcriptText = String(data.text || data.transcript || '').trim();
|
|
677
|
+
if (!transcriptText) throw new Error('ASR returned no transcript');
|
|
678
|
+
prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
|
|
679
|
+
prompt.focus();
|
|
680
|
+
setState('transcribed', 'ok');
|
|
681
|
+
}}
|
|
682
|
+
async function startVoiceInput() {{
|
|
683
|
+
if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
|
|
684
|
+
mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
|
|
685
|
+
recordingChunks = [];
|
|
686
|
+
mediaRecorder = new MediaRecorder(mediaStream);
|
|
687
|
+
mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
|
|
688
|
+
mediaRecorder.addEventListener('stop', async () => {{
|
|
689
|
+
const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
|
|
690
|
+
if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
|
|
691
|
+
mediaStream = null;
|
|
692
|
+
micButton.textContent = 'Start voice input';
|
|
693
|
+
micButton.classList.remove('recording');
|
|
694
|
+
try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
|
|
695
|
+
}}, {{once: true}});
|
|
696
|
+
mediaRecorder.start();
|
|
697
|
+
micButton.textContent = 'Stop and transcribe';
|
|
698
|
+
micButton.classList.add('recording');
|
|
699
|
+
setState('recording', 'error');
|
|
700
|
+
}}
|
|
701
|
+
function stopVoiceInput() {{
|
|
702
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
|
|
703
|
+
}}
|
|
474
704
|
async function uploadAttachment(file) {{
|
|
475
705
|
const content = await fileToBase64(file);
|
|
476
706
|
const response = await fetch('/ca/channel/files', {{
|
|
@@ -663,6 +893,54 @@ def render_web_chat_page(
|
|
|
663
893
|
}}
|
|
664
894
|
}});
|
|
665
895
|
attachButton.addEventListener('click', () => fileInput.click());
|
|
896
|
+
micButton.addEventListener('click', async () => {{
|
|
897
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
898
|
+
stopVoiceInput();
|
|
899
|
+
return;
|
|
900
|
+
}}
|
|
901
|
+
try {{ await startVoiceInput(); }} catch (err) {{
|
|
902
|
+
setState('microphone error', 'error');
|
|
903
|
+
addBubble('system', 'Microphone failed: ' + String(err && err.message ? err.message : err));
|
|
904
|
+
}}
|
|
905
|
+
}});
|
|
906
|
+
speechSettingsButton.addEventListener('click', async () => {{
|
|
907
|
+
try {{ await loadSpeechConfig(); }} catch (err) {{ addBubble('system', 'Could not load speech settings: ' + String(err && err.message ? err.message : err)); }}
|
|
908
|
+
speechSettingsDialog.showModal();
|
|
909
|
+
}});
|
|
910
|
+
speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
|
|
911
|
+
document.getElementById('ttsReferenceAudio').addEventListener('change', async event => {{
|
|
912
|
+
const file = event.target.files && event.target.files[0];
|
|
913
|
+
if (!file) return;
|
|
914
|
+
if (file.size > 10 * 1024 * 1024) {{
|
|
915
|
+
event.target.value = '';
|
|
916
|
+
addBubble('system', 'Reference voice must be 10 MB or smaller.');
|
|
917
|
+
return;
|
|
918
|
+
}}
|
|
919
|
+
pendingTtsReferenceAudio = await fileToDataUrl(file);
|
|
920
|
+
document.getElementById('ttsClearReferenceAudio').checked = false;
|
|
921
|
+
document.getElementById('ttsReferenceAudioStatus').textContent = file.name + ' (' + formatBytes(file.size) + ') ready to save';
|
|
922
|
+
}});
|
|
923
|
+
speechSettingsForm.addEventListener('submit', async event => {{
|
|
924
|
+
event.preventDefault();
|
|
925
|
+
try {{
|
|
926
|
+
await saveSpeechConfig();
|
|
927
|
+
speechSettingsDialog.close();
|
|
928
|
+
setState('speech settings saved', 'ok');
|
|
929
|
+
}} catch (err) {{
|
|
930
|
+
setState('settings error', 'error');
|
|
931
|
+
addBubble('system', 'Speech settings failed: ' + String(err && err.message ? err.message : err));
|
|
932
|
+
}}
|
|
933
|
+
}});
|
|
934
|
+
speechHealthButton.addEventListener('click', async () => {{
|
|
935
|
+
try {{
|
|
936
|
+
await saveSpeechConfig();
|
|
937
|
+
const response = await fetch('/ca/speech/health', {{headers: {{'accept': 'application/json'}}}});
|
|
938
|
+
const data = await response.json();
|
|
939
|
+
const asr = data.services && data.services.asr;
|
|
940
|
+
const tts = data.services && data.services.tts;
|
|
941
|
+
addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
|
|
942
|
+
}} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
|
|
943
|
+
}});
|
|
666
944
|
fileInput.addEventListener('change', () => {{
|
|
667
945
|
addSelectedFiles(fileInput.files);
|
|
668
946
|
fileInput.value = '';
|
|
@@ -706,6 +984,7 @@ def render_web_chat_page(
|
|
|
706
984
|
if (transcript.scrollTop < 48) loadOlderHistory();
|
|
707
985
|
}});
|
|
708
986
|
addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
|
|
987
|
+
loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
|
|
709
988
|
loadInitialHistory().finally(startChannelStream);
|
|
710
989
|
prompt.focus();
|
|
711
990
|
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Colab speech workers
|
|
2
|
+
|
|
3
|
+
Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab workers over a tailnet-only Tailscale tunnel.
|
|
4
|
+
|
|
5
|
+
## One-time prerequisites
|
|
6
|
+
|
|
7
|
+
1. Authenticate the Colab CLI inside WSL. The installed CLI currently uses Google Application Default Credentials, so run `~/google-cloud-sdk/bin/gcloud auth application-default login` in `Ubuntu-26.04`.
|
|
8
|
+
2. Create a reusable Tailscale auth key (two workers must register) and set it only for the current PowerShell process with `$env:TAILSCALE_AUTHKEY = Read-Host`. Alternatively, use a separate fresh key for each worker. The CLI passes the key without writing it to the repository. A Colab `TAILSCALE_AUTHKEY` Secret is also supported as a fallback.
|
|
9
|
+
3. Optionally set `$env:CIEL_SPEECH_API_KEY = Read-Host` before deployment. Enter that value once in Web Chat > Speech Settings; Ciel stores it server-side and never returns it to the browser.
|
|
10
|
+
|
|
11
|
+
## Deploy
|
|
12
|
+
|
|
13
|
+
From PowerShell at the repository root:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
.\scripts\deploy_colab_speech.ps1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The script creates `ciel-asr` and `ciel-tts` T4 sessions, installs Qwen3-ASR-0.6B and MOSS-TTS-Nano, starts Tailscale in userspace networking mode, publishes each localhost model server with Tailscale Serve, and saves both returned `base_url` values into Web Chat > Speech Settings automatically.
|
|
20
|
+
|
|
21
|
+
MOSS-TTS-Nano is a voice-cloning model without built-in speakers. Deployment configures the project's official `zh_1.wav` sample so the first request works immediately. In Web Chat > Speech Settings, upload a reference voice clip (10 MB maximum) to replace it. Ciel stores uploaded audio only in the local protected runtime configuration, omits it from configuration responses, and adds it to TTS requests automatically. API clients can instead pass `ref_audio` as an HTTP(S) URL or base64 audio data URL to `POST /v1/audio/speech`.
|
|
22
|
+
|
|
23
|
+
Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
|
|
24
|
+
|
|
25
|
+
## API surface
|
|
26
|
+
|
|
27
|
+
- `GET|POST /ca/speech/config`
|
|
28
|
+
- `GET /ca/speech/health`
|
|
29
|
+
- `POST /v1/audio/transcriptions`
|
|
30
|
+
- `POST /v1/audio/translations`
|
|
31
|
+
- `POST /v1/audio/speech`
|
|
32
|
+
- `POST /v1/audio/speech/batch`
|
|
33
|
+
- `GET|POST /v1/audio/voices`
|
|
34
|
+
- `GET /ca/web/chat/api` lists chat, model, message, response, file, and speech endpoints.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneciel-ai/ciel-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "One Ciel LLC",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"ciel-runtime-stop.cmd",
|
|
37
37
|
"ciel-runtime-stop.ps1",
|
|
38
38
|
"npm-bin/",
|
|
39
|
+
"scripts/colab/",
|
|
40
|
+
"scripts/configure_speech_workers.py",
|
|
41
|
+
"scripts/deploy_colab_speech.ps1",
|
|
39
42
|
"install.sh",
|
|
40
43
|
"install.ps1",
|
|
41
44
|
"README.md",
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Bootstrap MOSS-TTS-Nano with vLLM-Omni on Colab and Tailscale Serve.
|
|
2
|
+
|
|
3
|
+
Required Colab Secret: TAILSCALE_AUTHKEY
|
|
4
|
+
Optional Colab Secret: CIEL_SPEECH_API_KEY
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import site
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
|
|
21
|
+
PORT = 8091
|
|
22
|
+
SOCKET = "/tmp/ciel-tts-tailscaled.sock"
|
|
23
|
+
STATE = "/tmp/ciel-tts-tailscaled.state"
|
|
24
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
28
|
+
value = str(os.environ.get(name) or "").strip()
|
|
29
|
+
if not value:
|
|
30
|
+
try:
|
|
31
|
+
from google.colab import userdata # type: ignore
|
|
32
|
+
|
|
33
|
+
value = str(userdata.get(name) or "").strip()
|
|
34
|
+
except Exception:
|
|
35
|
+
value = ""
|
|
36
|
+
if required and not value:
|
|
37
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
visible: list[str] = []
|
|
43
|
+
redact_next = False
|
|
44
|
+
for arg in args:
|
|
45
|
+
if redact_next:
|
|
46
|
+
visible.append("<redacted>")
|
|
47
|
+
redact_next = False
|
|
48
|
+
elif arg.startswith("--auth-key="):
|
|
49
|
+
visible.append("--auth-key=<redacted>")
|
|
50
|
+
else:
|
|
51
|
+
visible.append(arg)
|
|
52
|
+
redact_next = arg == "--api-key"
|
|
53
|
+
print("+", " ".join(visible))
|
|
54
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_tailscale() -> None:
|
|
58
|
+
if shutil.which("tailscale"):
|
|
59
|
+
return
|
|
60
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
64
|
+
install_tailscale()
|
|
65
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
|
|
67
|
+
if not Path(SOCKET).exists():
|
|
68
|
+
subprocess.Popen(
|
|
69
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
70
|
+
stdout=tail_log,
|
|
71
|
+
stderr=subprocess.STDOUT,
|
|
72
|
+
start_new_session=True,
|
|
73
|
+
)
|
|
74
|
+
for _ in range(60):
|
|
75
|
+
if Path(SOCKET).exists():
|
|
76
|
+
break
|
|
77
|
+
time.sleep(1)
|
|
78
|
+
login = run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset", check=False)
|
|
79
|
+
if login.returncode:
|
|
80
|
+
raise RuntimeError("Tailscale authentication failed; use a valid reusable key or a fresh key for this second worker")
|
|
81
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
82
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
83
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
84
|
+
return dns_name, f"http://{dns_name}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def wait_for_server(api_key: str, process: subprocess.Popen[bytes]) -> None:
|
|
88
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
89
|
+
for _ in range(240):
|
|
90
|
+
if process.poll() is not None:
|
|
91
|
+
log_path = LOG_DIR / "moss-tts.log"
|
|
92
|
+
log_tail = log_path.read_text(encoding="utf-8", errors="replace")[-12000:] if log_path.exists() else "log unavailable"
|
|
93
|
+
raise RuntimeError(f"MOSS TTS exited with status {process.returncode}:\n{log_tail}")
|
|
94
|
+
try:
|
|
95
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
96
|
+
if response.status < 500:
|
|
97
|
+
return
|
|
98
|
+
except Exception:
|
|
99
|
+
time.sleep(2)
|
|
100
|
+
raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def server_is_healthy(api_key: str) -> bool:
|
|
104
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
107
|
+
return response.status < 500
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> None:
|
|
113
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
114
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
115
|
+
run(
|
|
116
|
+
sys.executable,
|
|
117
|
+
"-m",
|
|
118
|
+
"pip",
|
|
119
|
+
"install",
|
|
120
|
+
"-U",
|
|
121
|
+
"nvidia-cuda-runtime==13.0.96",
|
|
122
|
+
"vllm==0.24.0",
|
|
123
|
+
"vllm-omni==0.24.0",
|
|
124
|
+
)
|
|
125
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
if not server_is_healthy(api_key):
|
|
127
|
+
command = [
|
|
128
|
+
"vllm-omni", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
|
|
129
|
+
"--gpu-memory-utilization", "0.72",
|
|
130
|
+
]
|
|
131
|
+
if api_key:
|
|
132
|
+
command.extend(["--api-key", api_key])
|
|
133
|
+
server_env = os.environ.copy()
|
|
134
|
+
cuda_runtime_libraries = [
|
|
135
|
+
library
|
|
136
|
+
for package_dir in site.getsitepackages()
|
|
137
|
+
for library in Path(package_dir).glob("**/libcudart.so.13")
|
|
138
|
+
]
|
|
139
|
+
if cuda_runtime_libraries:
|
|
140
|
+
existing_library_path = server_env.get("LD_LIBRARY_PATH", "")
|
|
141
|
+
server_env["LD_LIBRARY_PATH"] = str(cuda_runtime_libraries[0].parent) + (f":{existing_library_path}" if existing_library_path else "")
|
|
142
|
+
server_log = (LOG_DIR / "moss-tts.log").open("ab")
|
|
143
|
+
process = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True, env=server_env)
|
|
144
|
+
wait_for_server(api_key, process)
|
|
145
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
146
|
+
print(json.dumps({"ok": True, "role": "tts", "hostname": dns_name, "base_url": base_url, "model": "OpenMOSS-Team/MOSS-TTS-Nano", "api_key_set": bool(api_key)}, indent=2))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Bootstrap Qwen3-ASR on a Colab T4 and publish it only to the tailnet.
|
|
2
|
+
|
|
3
|
+
Required Colab Secret: TAILSCALE_AUTHKEY
|
|
4
|
+
Optional Colab Secret: CIEL_SPEECH_API_KEY
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOSTNAME = os.environ.get("CIEL_ASR_HOSTNAME", "ciel-asr")
|
|
20
|
+
PORT = 8000
|
|
21
|
+
SOCKET = "/tmp/ciel-asr-tailscaled.sock"
|
|
22
|
+
STATE = "/tmp/ciel-asr-tailscaled.state"
|
|
23
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
27
|
+
value = str(os.environ.get(name) or "").strip()
|
|
28
|
+
if not value:
|
|
29
|
+
try:
|
|
30
|
+
from google.colab import userdata # type: ignore
|
|
31
|
+
|
|
32
|
+
value = str(userdata.get(name) or "").strip()
|
|
33
|
+
except Exception:
|
|
34
|
+
value = ""
|
|
35
|
+
if required and not value:
|
|
36
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
visible: list[str] = []
|
|
42
|
+
redact_next = False
|
|
43
|
+
for arg in args:
|
|
44
|
+
if redact_next:
|
|
45
|
+
visible.append("<redacted>")
|
|
46
|
+
redact_next = False
|
|
47
|
+
elif arg.startswith("--auth-key="):
|
|
48
|
+
visible.append("--auth-key=<redacted>")
|
|
49
|
+
else:
|
|
50
|
+
visible.append(arg)
|
|
51
|
+
redact_next = arg == "--api-key"
|
|
52
|
+
print("+", " ".join(visible))
|
|
53
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def install_tailscale() -> None:
|
|
57
|
+
if shutil.which("tailscale"):
|
|
58
|
+
return
|
|
59
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
63
|
+
install_tailscale()
|
|
64
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
tail_log = (LOG_DIR / "tailscale-asr.log").open("ab")
|
|
66
|
+
subprocess.Popen(
|
|
67
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
68
|
+
stdout=tail_log,
|
|
69
|
+
stderr=subprocess.STDOUT,
|
|
70
|
+
start_new_session=True,
|
|
71
|
+
)
|
|
72
|
+
for _ in range(60):
|
|
73
|
+
if Path(SOCKET).exists():
|
|
74
|
+
break
|
|
75
|
+
time.sleep(1)
|
|
76
|
+
login = run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset", check=False)
|
|
77
|
+
if login.returncode:
|
|
78
|
+
raise RuntimeError("Tailscale authentication failed; use a valid reusable key or a fresh key for this worker")
|
|
79
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
80
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
81
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
82
|
+
return dns_name, f"http://{dns_name}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def wait_for_server(api_key: str) -> None:
|
|
86
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
87
|
+
for _ in range(180):
|
|
88
|
+
try:
|
|
89
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
90
|
+
if response.status < 500:
|
|
91
|
+
return
|
|
92
|
+
except Exception:
|
|
93
|
+
time.sleep(2)
|
|
94
|
+
raise RuntimeError("Qwen ASR did not become healthy; inspect /content/ciel-speech-logs/qwen-asr.log")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
99
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
100
|
+
run(sys.executable, "-m", "pip", "install", "-U", "qwen-asr[vllm]", "vllm[audio]")
|
|
101
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
command = [
|
|
103
|
+
"qwen-asr-serve", "Qwen/Qwen3-ASR-0.6B", "--host", "127.0.0.1", "--port", str(PORT),
|
|
104
|
+
"--gpu-memory-utilization", "0.78", "--max-model-len", "8192",
|
|
105
|
+
]
|
|
106
|
+
if api_key:
|
|
107
|
+
command.extend(["--api-key", api_key])
|
|
108
|
+
server_log = (LOG_DIR / "qwen-asr.log").open("ab")
|
|
109
|
+
subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
110
|
+
wait_for_server(api_key)
|
|
111
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
112
|
+
print(json.dumps({"ok": True, "role": "asr", "hostname": dns_name, "base_url": base_url, "model": "Qwen/Qwen3-ASR-0.6B", "api_key_set": bool(api_key)}, indent=2))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Persist deployed speech worker URLs in the active Ciel Runtime config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_TTS_REFERENCE_AUDIO = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure(asr_base_url: str, tts_base_url: str, tts_reference_audio: str = DEFAULT_TTS_REFERENCE_AUDIO) -> dict[str, Any]:
|
|
17
|
+
import ciel_runtime
|
|
18
|
+
|
|
19
|
+
config = ciel_runtime.load_config()
|
|
20
|
+
speech = config.setdefault("speech", {})
|
|
21
|
+
asr = speech.setdefault("asr", {})
|
|
22
|
+
tts = speech.setdefault("tts", {})
|
|
23
|
+
asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
|
|
24
|
+
tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
|
|
25
|
+
if tts_reference_audio and not str(tts.get("ref_audio") or "").strip():
|
|
26
|
+
tts["ref_audio"] = tts_reference_audio
|
|
27
|
+
ciel_runtime.save_config(config)
|
|
28
|
+
return {"asr": asr["base_url"], "tts": tts["base_url"]}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> int:
|
|
32
|
+
parser = argparse.ArgumentParser()
|
|
33
|
+
parser.add_argument("--asr-base-url", required=True)
|
|
34
|
+
parser.add_argument("--tts-base-url", required=True)
|
|
35
|
+
parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
result = configure(args.asr_base_url, args.tts_base_url, args.tts_reference_audio)
|
|
38
|
+
print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Distribution = "Ubuntu-26.04",
|
|
3
|
+
[string]$AsrSession = "ciel-asr",
|
|
4
|
+
[string]$TtsSession = "ciel-tts"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = "Stop"
|
|
8
|
+
$repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
9
|
+
$wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
|
|
10
|
+
if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
|
|
11
|
+
$bootstrapEnv = ""
|
|
12
|
+
if ($env:TAILSCALE_AUTHKEY) {
|
|
13
|
+
if ($env:TAILSCALE_AUTHKEY -notmatch '^tskey-[A-Za-z0-9_-]+$') { throw "TAILSCALE_AUTHKEY has an unexpected format." }
|
|
14
|
+
$bootstrapEnv += " --env TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)"
|
|
15
|
+
}
|
|
16
|
+
if ($env:CIEL_SPEECH_API_KEY) {
|
|
17
|
+
if ($env:CIEL_SPEECH_API_KEY -match '[\s''"]') { throw "CIEL_SPEECH_API_KEY cannot contain whitespace or quotes for CLI deployment." }
|
|
18
|
+
$bootstrapEnv += " --env CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Write-Host "Checking Colab CLI authentication..."
|
|
22
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc status >/dev/null"
|
|
23
|
+
if ($LASTEXITCODE -ne 0) {
|
|
24
|
+
throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Write-Host "Creating ASR T4 session: $AsrSession"
|
|
28
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$AsrSession'"
|
|
29
|
+
if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
|
|
30
|
+
|
|
31
|
+
Write-Host "Creating TTS T4 session: $TtsSession"
|
|
32
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$TtsSession'"
|
|
33
|
+
if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
|
|
34
|
+
|
|
35
|
+
Write-Host "Installing Qwen3-ASR and its Tailscale service..."
|
|
36
|
+
$asrOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$AsrSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
|
|
37
|
+
if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
|
|
38
|
+
|
|
39
|
+
Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
|
|
40
|
+
$ttsOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$TtsSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
|
|
41
|
+
if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
|
|
42
|
+
|
|
43
|
+
function Read-BootstrapResult([string]$Text, [string]$Role) {
|
|
44
|
+
$matches = [regex]::Matches($Text, '(?s)\{\s*"ok"\s*:\s*true.*?\}')
|
|
45
|
+
if ($matches.Count -eq 0) { throw "Could not find the $Role bootstrap result in Colab output." }
|
|
46
|
+
$result = $matches[$matches.Count - 1].Value | ConvertFrom-Json
|
|
47
|
+
if ($result.role -ne $Role -or -not $result.base_url) { throw "Invalid $Role bootstrap result." }
|
|
48
|
+
return $result
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
$asr = Read-BootstrapResult $asrOutput "asr"
|
|
52
|
+
$tts = Read-BootstrapResult $ttsOutput "tts"
|
|
53
|
+
& python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url
|
|
54
|
+
if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
|
|
55
|
+
|
|
56
|
+
Write-Host "Both services are running and connected to Web Chat > Speech Settings."
|