@oneciel-ai/ciel-runtime 0.2.4 → 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_support/config_repository.py +2 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/speech_http_controller.py +27 -4
- package/ciel_runtime_support/web_ui.py +32 -0
- package/docs/COLAB_SPEECH.md +4 -2
- package/package.json +1 -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 +66 -22
- package/scripts/colab/bootstrap_qwen_asr.py +15 -5
- package/scripts/configure_speech_workers.py +8 -2
- package/scripts/deploy_colab_speech.ps1 +14 -5
|
@@ -44,6 +44,8 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
44
44
|
"model": "OpenMOSS-Team/MOSS-TTS-Nano",
|
|
45
45
|
"voice": "default",
|
|
46
46
|
"language": "ko",
|
|
47
|
+
"ref_audio": "",
|
|
48
|
+
"ref_text": "",
|
|
47
49
|
"response_format": "wav",
|
|
48
50
|
"speed": 1.0,
|
|
49
51
|
"auto_speak": False,
|
|
@@ -65,8 +65,10 @@ class SpeechHttpController:
|
|
|
65
65
|
public: dict[str, Any] = {"ok": True}
|
|
66
66
|
for name in ("asr", "tts"):
|
|
67
67
|
source = speech.get(name) if isinstance(speech.get(name), dict) else {}
|
|
68
|
-
item = {key: value for key, value in source.items() if key
|
|
68
|
+
item = {key: value for key, value in source.items() if key not in {"api_key", "ref_audio"}}
|
|
69
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())
|
|
70
72
|
public[name] = item
|
|
71
73
|
tailscale = speech.get("tailscale")
|
|
72
74
|
public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
|
|
@@ -127,13 +129,15 @@ class SpeechHttpController:
|
|
|
127
129
|
current = {}
|
|
128
130
|
speech[name] = current
|
|
129
131
|
for key, value in incoming.items():
|
|
130
|
-
if key in {"api_key_set", "clear_api_key"}:
|
|
132
|
+
if key in {"api_key_set", "clear_api_key", "ref_audio_set", "clear_ref_audio"}:
|
|
131
133
|
continue
|
|
132
|
-
if key
|
|
134
|
+
if key in {"api_key", "ref_audio"} and not str(value or "").strip():
|
|
133
135
|
continue
|
|
134
136
|
current[key] = self._validated_value(name, key, value)
|
|
135
137
|
if incoming.get("clear_api_key") is True:
|
|
136
138
|
current["api_key"] = ""
|
|
139
|
+
if name == "tts" and incoming.get("clear_ref_audio") is True:
|
|
140
|
+
current["ref_audio"] = ""
|
|
137
141
|
tailscale = update.get("tailscale")
|
|
138
142
|
if isinstance(tailscale, dict):
|
|
139
143
|
current_tailscale = speech.setdefault("tailscale", {})
|
|
@@ -153,7 +157,7 @@ class SpeechHttpController:
|
|
|
153
157
|
def _validated_value(service: str, key: str, value: Any) -> Any:
|
|
154
158
|
allowed = {
|
|
155
159
|
"asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
|
|
156
|
-
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "response_format", "speed", "auto_speak", "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"},
|
|
157
161
|
}
|
|
158
162
|
if key not in allowed[service]:
|
|
159
163
|
raise ValueError(f"unsupported {service} setting: {key}")
|
|
@@ -164,6 +168,21 @@ class SpeechHttpController:
|
|
|
164
168
|
if key == "speed":
|
|
165
169
|
return max(0.25, min(4.0, float(value)))
|
|
166
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
|
|
167
186
|
if key == "base_url":
|
|
168
187
|
parsed = urllib.parse.urlparse(text)
|
|
169
188
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
@@ -237,6 +256,10 @@ class SpeechHttpController:
|
|
|
237
256
|
body.setdefault("model", str(config.get("model") or ""))
|
|
238
257
|
body.setdefault("voice", str(config.get("voice") or "default"))
|
|
239
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"]))
|
|
240
263
|
body.setdefault("response_format", str(config.get("response_format") or "wav"))
|
|
241
264
|
body.setdefault("speed", float(config.get("speed") or 1.0))
|
|
242
265
|
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
@@ -224,6 +224,9 @@ def render_web_chat_page(
|
|
|
224
224
|
<label>Voice<input id="ttsVoice" placeholder="default"></label>
|
|
225
225
|
<label>Language<input id="ttsLanguage" placeholder="ko"></label>
|
|
226
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>
|
|
227
230
|
<label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
228
231
|
</div>
|
|
229
232
|
</section>
|
|
@@ -287,6 +290,7 @@ def render_web_chat_page(
|
|
|
287
290
|
let mediaRecorder = null;
|
|
288
291
|
let mediaStream = null;
|
|
289
292
|
let recordingChunks = [];
|
|
293
|
+
let pendingTtsReferenceAudio = '';
|
|
290
294
|
function setState(text, cls = '') {{
|
|
291
295
|
statePill.textContent = text;
|
|
292
296
|
statePill.className = 'pill ' + cls;
|
|
@@ -553,6 +557,14 @@ def render_web_chat_page(
|
|
|
553
557
|
reader.readAsDataURL(file);
|
|
554
558
|
}});
|
|
555
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
|
+
}}
|
|
556
568
|
function setSpeechForm(config) {{
|
|
557
569
|
const asr = config.asr || {{}};
|
|
558
570
|
const tts = config.tts || {{}};
|
|
@@ -569,6 +581,11 @@ def render_web_chat_page(
|
|
|
569
581
|
document.getElementById('ttsModel').value = tts.model || '';
|
|
570
582
|
document.getElementById('ttsVoice').value = tts.voice || 'default';
|
|
571
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 = '';
|
|
572
589
|
document.getElementById('ttsApiKey').value = '';
|
|
573
590
|
document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
574
591
|
document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
|
|
@@ -601,6 +618,9 @@ def render_web_chat_page(
|
|
|
601
618
|
model: document.getElementById('ttsModel').value,
|
|
602
619
|
voice: document.getElementById('ttsVoice').value,
|
|
603
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,
|
|
604
624
|
api_key: document.getElementById('ttsApiKey').value,
|
|
605
625
|
}},
|
|
606
626
|
tailscale: {{
|
|
@@ -888,6 +908,18 @@ def render_web_chat_page(
|
|
|
888
908
|
speechSettingsDialog.showModal();
|
|
889
909
|
}});
|
|
890
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
|
+
}});
|
|
891
923
|
speechSettingsForm.addEventListener('submit', async event => {{
|
|
892
924
|
event.preventDefault();
|
|
893
925
|
try {{
|
package/docs/COLAB_SPEECH.md
CHANGED
|
@@ -5,8 +5,8 @@ Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab
|
|
|
5
5
|
## One-time prerequisites
|
|
6
6
|
|
|
7
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
|
|
9
|
-
3. Optionally
|
|
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
10
|
|
|
11
11
|
## Deploy
|
|
12
12
|
|
|
@@ -18,6 +18,8 @@ From PowerShell at the repository root:
|
|
|
18
18
|
|
|
19
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
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
|
+
|
|
21
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.
|
|
22
24
|
|
|
23
25
|
## API surface
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
import site
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
@@ -38,7 +39,18 @@ def secret(name: str, *, required: bool = False) -> str:
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
-
|
|
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))
|
|
42
54
|
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
43
55
|
|
|
44
56
|
|
|
@@ -52,29 +64,33 @@ def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
|
52
64
|
install_tailscale()
|
|
53
65
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
66
|
tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
)
|
|
61
74
|
for _ in range(60):
|
|
62
75
|
if Path(SOCKET).exists():
|
|
63
76
|
break
|
|
64
77
|
time.sleep(1)
|
|
65
|
-
run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
|
|
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")
|
|
66
81
|
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
67
82
|
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
68
|
-
serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
|
|
69
|
-
if serve.returncode == 0:
|
|
70
|
-
return dns_name, f"https://{dns_name}"
|
|
71
83
|
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
84
|
return dns_name, f"http://{dns_name}"
|
|
73
85
|
|
|
74
86
|
|
|
75
|
-
def wait_for_server(api_key: str) -> None:
|
|
87
|
+
def wait_for_server(api_key: str, process: subprocess.Popen[bytes]) -> None:
|
|
76
88
|
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
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}")
|
|
78
94
|
try:
|
|
79
95
|
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
80
96
|
if response.status < 500:
|
|
@@ -84,20 +100,48 @@ def wait_for_server(api_key: str) -> None:
|
|
|
84
100
|
raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
|
|
85
101
|
|
|
86
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
|
+
|
|
87
112
|
def main() -> None:
|
|
88
113
|
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
89
114
|
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
90
|
-
run(
|
|
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
|
+
)
|
|
91
125
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
101
145
|
dns_name, base_url = start_tailscale(auth_key)
|
|
102
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))
|
|
103
147
|
|
|
@@ -38,7 +38,18 @@ def secret(name: str, *, required: bool = False) -> str:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
-
|
|
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))
|
|
42
53
|
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
43
54
|
|
|
44
55
|
|
|
@@ -62,12 +73,11 @@ def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
|
62
73
|
if Path(SOCKET).exists():
|
|
63
74
|
break
|
|
64
75
|
time.sleep(1)
|
|
65
|
-
run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
|
|
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")
|
|
66
79
|
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
67
80
|
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
68
|
-
serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
|
|
69
|
-
if serve.returncode == 0:
|
|
70
|
-
return dns_name, f"https://{dns_name}"
|
|
71
81
|
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
82
|
return dns_name, f"http://{dns_name}"
|
|
73
83
|
|
|
@@ -10,7 +10,10 @@ from typing import Any
|
|
|
10
10
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
|
|
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]:
|
|
14
17
|
import ciel_runtime
|
|
15
18
|
|
|
16
19
|
config = ciel_runtime.load_config()
|
|
@@ -19,6 +22,8 @@ def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
|
|
|
19
22
|
tts = speech.setdefault("tts", {})
|
|
20
23
|
asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
|
|
21
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
|
|
22
27
|
ciel_runtime.save_config(config)
|
|
23
28
|
return {"asr": asr["base_url"], "tts": tts["base_url"]}
|
|
24
29
|
|
|
@@ -27,8 +32,9 @@ def main() -> int:
|
|
|
27
32
|
parser = argparse.ArgumentParser()
|
|
28
33
|
parser.add_argument("--asr-base-url", required=True)
|
|
29
34
|
parser.add_argument("--tts-base-url", required=True)
|
|
35
|
+
parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
|
|
30
36
|
args = parser.parse_args()
|
|
31
|
-
result = configure(args.asr_base_url, args.tts_base_url)
|
|
37
|
+
result = configure(args.asr_base_url, args.tts_base_url, args.tts_reference_audio)
|
|
32
38
|
print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
|
|
33
39
|
return 0
|
|
34
40
|
|
|
@@ -8,27 +8,36 @@ $ErrorActionPreference = "Stop"
|
|
|
8
8
|
$repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
9
9
|
$wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
|
|
10
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
|
+
}
|
|
11
20
|
|
|
12
21
|
Write-Host "Checking Colab CLI authentication..."
|
|
13
|
-
& wsl -d $Distribution -- bash -lc "colab status >/dev/null"
|
|
22
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc status >/dev/null"
|
|
14
23
|
if ($LASTEXITCODE -ne 0) {
|
|
15
24
|
throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
Write-Host "Creating ASR T4 session: $AsrSession"
|
|
19
|
-
& wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$AsrSession'"
|
|
28
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$AsrSession'"
|
|
20
29
|
if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
|
|
21
30
|
|
|
22
31
|
Write-Host "Creating TTS T4 session: $TtsSession"
|
|
23
|
-
& wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$TtsSession'"
|
|
32
|
+
& wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$TtsSession'"
|
|
24
33
|
if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
|
|
25
34
|
|
|
26
35
|
Write-Host "Installing Qwen3-ASR and its Tailscale service..."
|
|
27
|
-
$asrOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$AsrSession' --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
|
|
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"
|
|
28
37
|
if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
|
|
29
38
|
|
|
30
39
|
Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
|
|
31
|
-
$ttsOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$TtsSession' --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
|
|
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"
|
|
32
41
|
if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
|
|
33
42
|
|
|
34
43
|
function Read-BootstrapResult([string]$Text, [string]$Role) {
|