@oneciel-ai/ciel-runtime 0.2.12 → 0.2.13
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 +3 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/speech_http_controller.py +41 -5
- package/ciel_runtime_support/web_ui.py +129 -19
- package/docs/COLAB_SPEECH.md +5 -1
- package/package.json +2 -2
- package/scripts/colab/bootstrap_cosyvoice3.py +125 -0
- package/scripts/colab/bootstrap_moss_tts.py +10 -0
- package/scripts/configure_speech_workers.py +23 -3
- package/scripts/deploy_colab_speech.ps1 +9 -4
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
|
@@ -37,6 +37,7 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
37
37
|
"tts_session": "ciel-tts",
|
|
38
38
|
"asr_accelerator": "T4",
|
|
39
39
|
"tts_accelerator": "T4",
|
|
40
|
+
"tts_backend": "moss",
|
|
40
41
|
},
|
|
41
42
|
"asr": {
|
|
42
43
|
"enabled": False,
|
|
@@ -63,6 +64,8 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
63
64
|
"response_format": "wav",
|
|
64
65
|
"speed": 1.0,
|
|
65
66
|
"auto_speak": False,
|
|
67
|
+
"streaming": False,
|
|
68
|
+
"sample_rate": 48000,
|
|
66
69
|
"api_key": "",
|
|
67
70
|
"timeout_seconds": 300,
|
|
68
71
|
},
|
|
@@ -204,12 +204,14 @@ class SpeechHttpController:
|
|
|
204
204
|
def _validated_value(service: str, key: str, value: Any) -> Any:
|
|
205
205
|
allowed = {
|
|
206
206
|
"asr": {"enabled", "base_url", "endpoint", "model", "language", "silence_ms", "min_speech_ms", "vad_threshold", "api_key", "timeout_seconds"},
|
|
207
|
-
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
|
|
207
|
+
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "streaming", "sample_rate", "api_key", "timeout_seconds"},
|
|
208
208
|
}
|
|
209
209
|
if key not in allowed[service]:
|
|
210
210
|
raise ValueError(f"unsupported {service} setting: {key}")
|
|
211
|
-
if key in {"enabled", "auto_speak"}:
|
|
211
|
+
if key in {"enabled", "auto_speak", "streaming"}:
|
|
212
212
|
return bool(value)
|
|
213
|
+
if key == "sample_rate":
|
|
214
|
+
return max(8000, min(192000, int(value)))
|
|
213
215
|
if key == "timeout_seconds":
|
|
214
216
|
return max(1, min(3600, int(value)))
|
|
215
217
|
if key == "speed":
|
|
@@ -256,6 +258,7 @@ class SpeechHttpController:
|
|
|
256
258
|
"tts_session",
|
|
257
259
|
"asr_accelerator",
|
|
258
260
|
"tts_accelerator",
|
|
261
|
+
"tts_backend",
|
|
259
262
|
}
|
|
260
263
|
if key not in allowed:
|
|
261
264
|
raise ValueError(f"unsupported colab setting: {key}")
|
|
@@ -272,6 +275,11 @@ class SpeechHttpController:
|
|
|
272
275
|
if accelerator not in {"T4", "L4", "G4", "A100", "H100"}:
|
|
273
276
|
raise ValueError("unsupported Colab accelerator")
|
|
274
277
|
return accelerator
|
|
278
|
+
if key == "tts_backend":
|
|
279
|
+
backend = text.lower()
|
|
280
|
+
if backend not in {"moss", "cosyvoice3"}:
|
|
281
|
+
raise ValueError("unsupported Colab TTS backend")
|
|
282
|
+
return backend
|
|
275
283
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", text):
|
|
276
284
|
raise ValueError(f"invalid Colab {key}")
|
|
277
285
|
return text
|
|
@@ -329,6 +337,7 @@ class SpeechHttpController:
|
|
|
329
337
|
|
|
330
338
|
def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
|
|
331
339
|
config = self._service_config("tts")
|
|
340
|
+
streaming = False
|
|
332
341
|
if not self._require_enabled(handler, "tts", config):
|
|
333
342
|
return True
|
|
334
343
|
if "application/json" in content_type.lower():
|
|
@@ -344,14 +353,17 @@ class SpeechHttpController:
|
|
|
344
353
|
body.setdefault("ref_audio", str(config["ref_audio"]))
|
|
345
354
|
if str(config.get("ref_text") or "").strip():
|
|
346
355
|
body.setdefault("ref_text", str(config["ref_text"]))
|
|
356
|
+
if "CosyVoice3" in str(body.get("model") or "") and (not str(body.get("ref_audio") or "").strip() or not str(body.get("ref_text") or "").strip()):
|
|
357
|
+
raise ValueError("CosyVoice 3 requires both ref_audio and its exact ref_text transcript")
|
|
347
358
|
body.setdefault("response_format", str(config.get("response_format") or "wav"))
|
|
348
359
|
body.setdefault("speed", float(config.get("speed") or 1.0))
|
|
360
|
+
streaming = bool(body.get("stream") and body.get("stream_format") == "audio")
|
|
349
361
|
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
350
362
|
except (ValueError, TypeError, UnicodeError) as exc:
|
|
351
363
|
self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
|
|
352
364
|
return True
|
|
353
365
|
endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
|
|
354
|
-
return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
|
|
366
|
+
return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type, streaming=streaming)
|
|
355
367
|
|
|
356
368
|
def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
|
|
357
369
|
config = self._service_config(service)
|
|
@@ -359,14 +371,38 @@ class SpeechHttpController:
|
|
|
359
371
|
return True
|
|
360
372
|
return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
|
|
361
373
|
|
|
362
|
-
def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
|
|
374
|
+
def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str, *, streaming: bool = False) -> bool:
|
|
363
375
|
request = urllib.request.Request(
|
|
364
376
|
self._url(config, endpoint),
|
|
365
377
|
data=raw,
|
|
366
378
|
headers=self._headers(config, content_type or "application/octet-stream"),
|
|
367
379
|
method="POST",
|
|
368
380
|
)
|
|
369
|
-
return self._open_and_write(handler, service, config, request)
|
|
381
|
+
return self._open_and_stream(handler, service, config, request) if streaming else self._open_and_write(handler, service, config, request)
|
|
382
|
+
|
|
383
|
+
def _open_and_stream(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
|
|
384
|
+
started = False
|
|
385
|
+
try:
|
|
386
|
+
with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
|
|
387
|
+
handler.send_response(int(getattr(response, "status", 200)))
|
|
388
|
+
handler.send_header("content-type", str(response.headers.get("content-type") or "audio/pcm"))
|
|
389
|
+
handler.send_header("cache-control", "no-store")
|
|
390
|
+
handler.send_header("connection", "close")
|
|
391
|
+
handler.end_headers()
|
|
392
|
+
started = True
|
|
393
|
+
handler.close_connection = True
|
|
394
|
+
read_chunk = getattr(response, "read1", response.read)
|
|
395
|
+
while chunk := read_chunk(16 * 1024):
|
|
396
|
+
handler.wfile.write(chunk)
|
|
397
|
+
handler.wfile.flush()
|
|
398
|
+
except urllib.error.HTTPError as exc:
|
|
399
|
+
if not started:
|
|
400
|
+
self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
self.ports.log("ERROR", f"speech_stream_failed service={service} error={type(exc).__name__}: {exc}")
|
|
403
|
+
if not started:
|
|
404
|
+
self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
|
|
405
|
+
return True
|
|
370
406
|
|
|
371
407
|
def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
|
|
372
408
|
try:
|
|
@@ -238,12 +238,14 @@ def render_web_chat_page(
|
|
|
238
238
|
<div class="settings-grid">
|
|
239
239
|
<label class="check"><input id="ttsEnabled" type="checkbox"> Enable TTS</label>
|
|
240
240
|
<label class="check"><input id="ttsAutoSpeak" type="checkbox"> Speak replies automatically</label>
|
|
241
|
+
<label class="check"><input id="ttsStreaming" type="checkbox"> Stream audio while it is generated</label>
|
|
241
242
|
<label class="wide">Tailscale base URL<input id="ttsBaseUrl" placeholder="http://ciel-tts:8091"></label>
|
|
242
243
|
<label>Voice<input id="ttsVoice" placeholder="default"></label>
|
|
243
244
|
<label>Language<input id="ttsLanguage" placeholder="ko"></label>
|
|
245
|
+
<label>PCM sample rate<input id="ttsSampleRate" type="number" min="8000" max="192000" step="1000"></label>
|
|
244
246
|
<label class="wide">Model<input id="ttsModel" placeholder="OpenMOSS-Team/MOSS-TTS-Nano"></label>
|
|
245
247
|
<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>
|
|
246
|
-
<label class="wide">Reference transcript (
|
|
248
|
+
<label class="wide">Reference transcript (required by CosyVoice 3)<input id="ttsReferenceText" placeholder="Exact transcript of the reference clip"></label>
|
|
247
249
|
<label class="check wide"><input id="ttsClearReferenceAudio" type="checkbox"> Remove the saved reference voice</label>
|
|
248
250
|
<label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
249
251
|
</div>
|
|
@@ -255,6 +257,7 @@ def render_web_chat_page(
|
|
|
255
257
|
<label>WSL distribution<input id="colabDistribution" placeholder="Ubuntu-26.04"></label>
|
|
256
258
|
<label>Authentication<select id="colabAuth"><option value="adc">ADC</option><option value="oauth2">OAuth2</option></select></label>
|
|
257
259
|
<label>Account profile<input id="colabProfile" placeholder="default"></label>
|
|
260
|
+
<label>TTS engine<select id="colabTtsBackend"><option value="moss">MOSS-TTS-Nano</option><option value="cosyvoice3">Fun-CosyVoice 3</option></select></label>
|
|
258
261
|
<label>ASR session<input id="colabAsrSession" placeholder="ciel-asr"></label>
|
|
259
262
|
<label>TTS session<input id="colabTtsSession" placeholder="ciel-tts"></label>
|
|
260
263
|
<label>ASR GPU<select id="colabAsrAccelerator"><option>T4</option><option>L4</option><option>G4</option><option>A100</option><option>H100</option></select></label>
|
|
@@ -276,7 +279,7 @@ def render_web_chat_page(
|
|
|
276
279
|
</div>
|
|
277
280
|
<div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
|
|
278
281
|
</section>
|
|
279
|
-
<div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
|
|
282
|
+
<div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="ghost" id="speechPlaybackTestButton" type="button">Test voice</button><button class="primary" type="submit">Save</button></div>
|
|
280
283
|
</div>
|
|
281
284
|
</form>
|
|
282
285
|
</dialog>
|
|
@@ -312,6 +315,7 @@ def render_web_chat_page(
|
|
|
312
315
|
const speechSettingsForm = document.getElementById('speechSettingsForm');
|
|
313
316
|
const speechSettingsClose = document.getElementById('speechSettingsClose');
|
|
314
317
|
const speechHealthButton = document.getElementById('speechHealthButton');
|
|
318
|
+
const speechPlaybackTestButton = document.getElementById('speechPlaybackTestButton');
|
|
315
319
|
const statePill = document.getElementById('statePill');
|
|
316
320
|
const SESSION_KEY = 'ciel-runtime-web-chat-session';
|
|
317
321
|
const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
|
|
@@ -357,6 +361,9 @@ def render_web_chat_page(
|
|
|
357
361
|
let liveUtteranceSerial = 0;
|
|
358
362
|
let activeSpeechAudio = null;
|
|
359
363
|
let activeSpeechUrl = '';
|
|
364
|
+
let speechPlaybackContext = null;
|
|
365
|
+
let activeSpeechSource = null;
|
|
366
|
+
const activeSpeechSources = new Set();
|
|
360
367
|
let speechGenerationController = null;
|
|
361
368
|
let pendingTtsReferenceAudio = '';
|
|
362
369
|
function setState(text, cls = '') {{
|
|
@@ -739,10 +746,12 @@ def render_web_chat_page(
|
|
|
739
746
|
document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
740
747
|
document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
|
|
741
748
|
document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
|
|
749
|
+
document.getElementById('ttsStreaming').checked = Boolean(tts.streaming);
|
|
742
750
|
document.getElementById('ttsBaseUrl').value = tts.base_url || '';
|
|
743
751
|
document.getElementById('ttsModel').value = tts.model || '';
|
|
744
752
|
document.getElementById('ttsVoice').value = tts.voice || 'default';
|
|
745
753
|
document.getElementById('ttsLanguage').value = tts.language || 'ko';
|
|
754
|
+
document.getElementById('ttsSampleRate').value = tts.sample_rate || 48000;
|
|
746
755
|
document.getElementById('ttsReferenceText').value = tts.ref_text || '';
|
|
747
756
|
document.getElementById('ttsReferenceAudioStatus').textContent = tts.ref_audio_set ? 'Reference voice saved securely on this Ciel router' : 'No reference voice configured';
|
|
748
757
|
document.getElementById('ttsClearReferenceAudio').checked = false;
|
|
@@ -754,6 +763,7 @@ def render_web_chat_page(
|
|
|
754
763
|
document.getElementById('colabDistribution').value = colab.distribution || 'Ubuntu-26.04';
|
|
755
764
|
document.getElementById('colabAuth').value = colab.auth || 'adc';
|
|
756
765
|
document.getElementById('colabProfile').value = colab.profile || 'default';
|
|
766
|
+
document.getElementById('colabTtsBackend').value = colab.tts_backend || (String(tts.model || '').includes('CosyVoice3') ? 'cosyvoice3' : 'moss');
|
|
757
767
|
document.getElementById('colabAsrSession').value = colab.asr_session || 'ciel-asr';
|
|
758
768
|
document.getElementById('colabTtsSession').value = colab.tts_session || 'ciel-tts';
|
|
759
769
|
document.getElementById('colabAsrAccelerator').value = colab.asr_accelerator || 'T4';
|
|
@@ -790,10 +800,12 @@ def render_web_chat_page(
|
|
|
790
800
|
tts: {{
|
|
791
801
|
enabled: document.getElementById('ttsEnabled').checked,
|
|
792
802
|
auto_speak: document.getElementById('ttsAutoSpeak').checked,
|
|
803
|
+
streaming: document.getElementById('ttsStreaming').checked,
|
|
793
804
|
base_url: document.getElementById('ttsBaseUrl').value,
|
|
794
805
|
model: document.getElementById('ttsModel').value,
|
|
795
806
|
voice: document.getElementById('ttsVoice').value,
|
|
796
807
|
language: document.getElementById('ttsLanguage').value,
|
|
808
|
+
sample_rate: Number(document.getElementById('ttsSampleRate').value || 48000),
|
|
797
809
|
ref_audio: pendingTtsReferenceAudio,
|
|
798
810
|
ref_text: document.getElementById('ttsReferenceText').value,
|
|
799
811
|
clear_ref_audio: document.getElementById('ttsClearReferenceAudio').checked,
|
|
@@ -804,6 +816,7 @@ def render_web_chat_page(
|
|
|
804
816
|
distribution: document.getElementById('colabDistribution').value,
|
|
805
817
|
auth: document.getElementById('colabAuth').value,
|
|
806
818
|
profile: document.getElementById('colabProfile').value,
|
|
819
|
+
tts_backend: document.getElementById('colabTtsBackend').value,
|
|
807
820
|
asr_session: document.getElementById('colabAsrSession').value,
|
|
808
821
|
tts_session: document.getElementById('colabTtsSession').value,
|
|
809
822
|
asr_accelerator: document.getElementById('colabAsrAccelerator').value,
|
|
@@ -864,9 +877,22 @@ def render_web_chat_page(
|
|
|
864
877
|
setTimeout(() => pollColabJob().catch(() => {{}}), 1000);
|
|
865
878
|
return data;
|
|
866
879
|
}}
|
|
880
|
+
function unlockSpeechPlayback() {{
|
|
881
|
+
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
882
|
+
if (!AudioContextClass) return null;
|
|
883
|
+
if (!speechPlaybackContext || speechPlaybackContext.state === 'closed') speechPlaybackContext = new AudioContextClass();
|
|
884
|
+
if (speechPlaybackContext.state === 'suspended') speechPlaybackContext.resume().catch(() => {{}});
|
|
885
|
+
return speechPlaybackContext;
|
|
886
|
+
}}
|
|
867
887
|
function stopActiveSpeech() {{
|
|
868
888
|
if (speechGenerationController) speechGenerationController.abort();
|
|
869
889
|
speechGenerationController = null;
|
|
890
|
+
if (activeSpeechSource) {{
|
|
891
|
+
try {{ activeSpeechSource.stop(); }} catch {{}}
|
|
892
|
+
activeSpeechSource = null;
|
|
893
|
+
}}
|
|
894
|
+
activeSpeechSources.forEach(source => {{ try {{ source.stop(); }} catch {{}} }});
|
|
895
|
+
activeSpeechSources.clear();
|
|
870
896
|
if (activeSpeechAudio) {{
|
|
871
897
|
activeSpeechAudio.pause();
|
|
872
898
|
activeSpeechAudio.currentTime = 0;
|
|
@@ -875,6 +901,87 @@ def render_web_chat_page(
|
|
|
875
901
|
if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
|
|
876
902
|
activeSpeechUrl = '';
|
|
877
903
|
}}
|
|
904
|
+
async function playSpeechBlob(blob) {{
|
|
905
|
+
const context = speechPlaybackContext;
|
|
906
|
+
if (context && context.state === 'running') {{
|
|
907
|
+
try {{
|
|
908
|
+
const audioBuffer = await context.decodeAudioData(await blob.arrayBuffer());
|
|
909
|
+
const source = context.createBufferSource();
|
|
910
|
+
source.buffer = audioBuffer;
|
|
911
|
+
source.connect(context.destination);
|
|
912
|
+
activeSpeechSource = source;
|
|
913
|
+
activeSpeechSources.add(source);
|
|
914
|
+
source.addEventListener('ended', () => {{
|
|
915
|
+
activeSpeechSources.delete(source);
|
|
916
|
+
if (activeSpeechSource === source) {{
|
|
917
|
+
activeSpeechSource = null;
|
|
918
|
+
setState(liveVoiceEnabled ? 'listening' : 'ready', 'ok');
|
|
919
|
+
}}
|
|
920
|
+
}}, {{once: true}});
|
|
921
|
+
source.start();
|
|
922
|
+
return;
|
|
923
|
+
}} catch {{}}
|
|
924
|
+
}}
|
|
925
|
+
activeSpeechUrl = URL.createObjectURL(blob);
|
|
926
|
+
activeSpeechAudio = new Audio(activeSpeechUrl);
|
|
927
|
+
const audio = activeSpeechAudio;
|
|
928
|
+
audio.preload = 'auto';
|
|
929
|
+
audio.playsInline = true;
|
|
930
|
+
const cleanup = () => {{
|
|
931
|
+
if (activeSpeechAudio === audio) {{
|
|
932
|
+
activeSpeechAudio = null;
|
|
933
|
+
if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
|
|
934
|
+
activeSpeechUrl = '';
|
|
935
|
+
setState(liveVoiceEnabled ? 'listening' : 'ready', 'ok');
|
|
936
|
+
}}
|
|
937
|
+
}};
|
|
938
|
+
audio.addEventListener('ended', cleanup, {{once: true}});
|
|
939
|
+
audio.addEventListener('error', cleanup, {{once: true}});
|
|
940
|
+
await audio.play();
|
|
941
|
+
}}
|
|
942
|
+
async function playPcmSpeechStream(response, controller, sampleRate) {{
|
|
943
|
+
const context = unlockSpeechPlayback();
|
|
944
|
+
if (context && context.state === 'suspended') await context.resume();
|
|
945
|
+
if (!context || context.state !== 'running' || !response.body) throw new Error('Browser audio is locked; click Test voice once to enable playback');
|
|
946
|
+
const reader = response.body.getReader();
|
|
947
|
+
let remainder = new Uint8Array(0);
|
|
948
|
+
let nextStart = context.currentTime + 0.06;
|
|
949
|
+
let received = false;
|
|
950
|
+
while (true) {{
|
|
951
|
+
const part = await reader.read();
|
|
952
|
+
if (part.done) break;
|
|
953
|
+
if (controller.signal.aborted) {{ await reader.cancel(); return; }}
|
|
954
|
+
let bytes = part.value;
|
|
955
|
+
if (remainder.length) {{
|
|
956
|
+
const joined = new Uint8Array(remainder.length + bytes.length);
|
|
957
|
+
joined.set(remainder);
|
|
958
|
+
joined.set(bytes, remainder.length);
|
|
959
|
+
bytes = joined;
|
|
960
|
+
}}
|
|
961
|
+
const usable = bytes.length - (bytes.length % 2);
|
|
962
|
+
remainder = usable < bytes.length ? bytes.slice(usable) : new Uint8Array(0);
|
|
963
|
+
if (!usable) continue;
|
|
964
|
+
const samples = usable / 2;
|
|
965
|
+
const audioBuffer = context.createBuffer(1, samples, sampleRate);
|
|
966
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
967
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, usable);
|
|
968
|
+
for (let index = 0; index < samples; index += 1) channelData[index] = view.getInt16(index * 2, true) / 32768;
|
|
969
|
+
const source = context.createBufferSource();
|
|
970
|
+
source.buffer = audioBuffer;
|
|
971
|
+
source.connect(context.destination);
|
|
972
|
+
activeSpeechSources.add(source);
|
|
973
|
+
source.addEventListener('ended', () => {{
|
|
974
|
+
activeSpeechSources.delete(source);
|
|
975
|
+
if (!activeSpeechSources.size && !speechGenerationController) setState(liveVoiceEnabled ? 'listening' : 'ready', 'ok');
|
|
976
|
+
}}, {{once: true}});
|
|
977
|
+
const startsAt = Math.max(nextStart, context.currentTime + 0.03);
|
|
978
|
+
source.start(startsAt);
|
|
979
|
+
nextStart = startsAt + audioBuffer.duration;
|
|
980
|
+
if (!received) setState('speaking', 'ok');
|
|
981
|
+
received = true;
|
|
982
|
+
}}
|
|
983
|
+
if (!received) throw new Error('TTS returned an empty PCM stream');
|
|
984
|
+
}}
|
|
878
985
|
async function speakText(text) {{
|
|
879
986
|
if (!speechConfig.tts || !speechConfig.tts.enabled) {{
|
|
880
987
|
setState('TTS disabled', 'error');
|
|
@@ -886,34 +993,28 @@ def render_web_chat_page(
|
|
|
886
993
|
speechGenerationController = controller;
|
|
887
994
|
try {{
|
|
888
995
|
setState('generating speech');
|
|
996
|
+
const streamAudio = Boolean(speechConfig.tts.streaming);
|
|
997
|
+
const requestBody = {{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: streamAudio ? 'pcm' : (speechConfig.tts.response_format || 'wav')}};
|
|
998
|
+
if (streamAudio) Object.assign(requestBody, {{stream: true, stream_format: 'audio'}});
|
|
889
999
|
const response = await fetch('/v1/audio/speech', {{
|
|
890
1000
|
method: 'POST',
|
|
891
1001
|
headers: {{'content-type': 'application/json', 'accept': 'audio/*'}},
|
|
892
|
-
body: JSON.stringify(
|
|
1002
|
+
body: JSON.stringify(requestBody),
|
|
893
1003
|
signal: controller.signal,
|
|
894
1004
|
}});
|
|
895
1005
|
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
1006
|
+
if (streamAudio) {{
|
|
1007
|
+
await playPcmSpeechStream(response, controller, Number(speechConfig.tts.sample_rate || 24000));
|
|
1008
|
+
speechGenerationController = null;
|
|
1009
|
+
if (!activeSpeechSources.size) setState(liveVoiceEnabled ? 'listening' : 'ready', 'ok');
|
|
1010
|
+
return;
|
|
1011
|
+
}}
|
|
896
1012
|
const blob = await response.blob();
|
|
897
1013
|
if (!blob.size) throw new Error('TTS returned empty audio');
|
|
898
1014
|
if (blob.type && !blob.type.startsWith('audio/')) throw new Error(`TTS returned ${{blob.type}} instead of audio`);
|
|
899
1015
|
if (controller.signal.aborted) return;
|
|
900
1016
|
speechGenerationController = null;
|
|
901
|
-
|
|
902
|
-
activeSpeechAudio = new Audio(activeSpeechUrl);
|
|
903
|
-
const audio = activeSpeechAudio;
|
|
904
|
-
audio.preload = 'auto';
|
|
905
|
-
audio.playsInline = true;
|
|
906
|
-
const cleanup = () => {{
|
|
907
|
-
if (activeSpeechAudio === audio) {{
|
|
908
|
-
activeSpeechAudio = null;
|
|
909
|
-
if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
|
|
910
|
-
activeSpeechUrl = '';
|
|
911
|
-
if (liveVoiceEnabled) setState('listening', 'ok');
|
|
912
|
-
}}
|
|
913
|
-
}};
|
|
914
|
-
audio.addEventListener('ended', cleanup, {{once: true}});
|
|
915
|
-
audio.addEventListener('error', cleanup, {{once: true}});
|
|
916
|
-
await audio.play();
|
|
1017
|
+
await playSpeechBlob(blob);
|
|
917
1018
|
setState('speaking', 'ok');
|
|
918
1019
|
}} catch (err) {{
|
|
919
1020
|
if (err && err.name === 'AbortError') return;
|
|
@@ -1313,6 +1414,8 @@ def render_web_chat_page(
|
|
|
1313
1414
|
composer.requestSubmit();
|
|
1314
1415
|
}}
|
|
1315
1416
|
}});
|
|
1417
|
+
document.addEventListener('pointerdown', unlockSpeechPlayback, {{capture: true}});
|
|
1418
|
+
document.addEventListener('keydown', unlockSpeechPlayback, {{capture: true}});
|
|
1316
1419
|
attachButton.addEventListener('click', () => fileInput.click());
|
|
1317
1420
|
micButton.addEventListener('click', async () => {{
|
|
1318
1421
|
if (liveVoiceEnabled) {{
|
|
@@ -1370,6 +1473,13 @@ def render_web_chat_page(
|
|
|
1370
1473
|
addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
|
|
1371
1474
|
}} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
|
|
1372
1475
|
}});
|
|
1476
|
+
speechPlaybackTestButton.addEventListener('click', async () => {{
|
|
1477
|
+
try {{
|
|
1478
|
+
await saveSpeechConfig();
|
|
1479
|
+
unlockSpeechPlayback();
|
|
1480
|
+
await speakText('음성 재생 테스트입니다.');
|
|
1481
|
+
}} catch (err) {{ addBubble('system', 'Voice playback test failed: ' + String(err && err.message ? err.message : err)); }}
|
|
1482
|
+
}});
|
|
1373
1483
|
fileInput.addEventListener('change', () => {{
|
|
1374
1484
|
addSelectedFiles(fileInput.files);
|
|
1375
1485
|
fileInput.value = '';
|
package/docs/COLAB_SPEECH.md
CHANGED
|
@@ -18,7 +18,7 @@ From PowerShell at the repository root:
|
|
|
18
18
|
|
|
19
19
|
Set the WSL distribution, authentication mode, ASR/TTS session names, and accelerators in **Web Chat > Speech Settings > Colab CLI connection**. These values are available through `GET|POST /ca/speech/config`; Ciel does not store Colab credentials. The deployment script reads the saved values automatically. Command-line parameters such as `-Distribution`, `-ColabAuth`, `-AsrSession`, and `-AsrAccelerator` override them for one run.
|
|
20
20
|
|
|
21
|
-
The script reuses matching active sessions when possible, otherwise creates them, installs Qwen3-ASR-0.6B
|
|
21
|
+
The script reuses matching active sessions when possible, otherwise creates them, installs Qwen3-ASR-0.6B plus the selected TTS engine (MOSS-TTS-Nano or Fun-CosyVoice3-0.5B-2512), 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. Choose the TTS engine in the Colab section before **Recover & deploy**, or pass `-TtsBackend moss|cosyvoice3` to the script.
|
|
22
22
|
|
|
23
23
|
### Session recovery and account profiles
|
|
24
24
|
|
|
@@ -40,6 +40,10 @@ Tailscale and speech API keys entered in Web Chat are passed only to the selecte
|
|
|
40
40
|
|
|
41
41
|
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`.
|
|
42
42
|
|
|
43
|
+
CosyVoice 3 deployment configures its official zero-shot reference clip and exact transcript, enables 24 kHz PCM output streaming, and starts browser playback as chunks arrive. For a custom cloned voice, upload the clip and provide its exact transcript; CosyVoice 3 requires both. **Test voice** unlocks browser audio playback and verifies the complete Ciel-to-worker path.
|
|
44
|
+
|
|
45
|
+
CosyVoice's bi-streaming means incremental text input and incremental audio output. Ciel currently benefits from the audio-output half: lower time to first sound, bounded buffering, and immediate cancellation when the user interrupts. The agent channel still delivers complete `spoken` fields, so using the text-input half later requires forwarding agent tokens or sentence fragments to a persistent streaming TTS connection. It is not by itself a full-duplex conversation protocol.
|
|
46
|
+
|
|
43
47
|
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.
|
|
44
48
|
|
|
45
49
|
## Live voice
|
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.13",
|
|
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,7 +36,7 @@
|
|
|
36
36
|
"ciel-runtime-stop.cmd",
|
|
37
37
|
"ciel-runtime-stop.ps1",
|
|
38
38
|
"npm-bin/",
|
|
39
|
-
"scripts/colab
|
|
39
|
+
"scripts/colab/*.py",
|
|
40
40
|
"scripts/configure_speech_workers.py",
|
|
41
41
|
"scripts/deploy_colab_speech.ps1",
|
|
42
42
|
"install.sh",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Bootstrap Fun-CosyVoice3-0.5B with streaming vLLM-Omni and Tailscale Serve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
|
|
16
|
+
MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
|
|
17
|
+
PORT = 8091
|
|
18
|
+
SOCKET = "/tmp/ciel-tts-tailscaled.sock"
|
|
19
|
+
STATE = "/tmp/ciel-tts-tailscaled.state"
|
|
20
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
21
|
+
BACKEND_MARKER = Path("/content/ciel-speech-tts-backend")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
25
|
+
value = str(os.environ.get(name) or "").strip()
|
|
26
|
+
if not value:
|
|
27
|
+
try:
|
|
28
|
+
from google.colab import userdata # type: ignore
|
|
29
|
+
|
|
30
|
+
value = str(userdata.get(name) or "").strip()
|
|
31
|
+
except Exception:
|
|
32
|
+
value = ""
|
|
33
|
+
if required and not value:
|
|
34
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
39
|
+
visible: list[str] = []
|
|
40
|
+
for arg in args:
|
|
41
|
+
visible.append("--auth-key=<redacted>" if arg.startswith("--auth-key=") else arg)
|
|
42
|
+
print("+", " ".join(visible))
|
|
43
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install_tailscale() -> None:
|
|
47
|
+
if not shutil.which("tailscale"):
|
|
48
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
52
|
+
install_tailscale()
|
|
53
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
|
|
55
|
+
if not Path(SOCKET).exists():
|
|
56
|
+
subprocess.Popen(
|
|
57
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
58
|
+
stdout=tail_log,
|
|
59
|
+
stderr=subprocess.STDOUT,
|
|
60
|
+
start_new_session=True,
|
|
61
|
+
)
|
|
62
|
+
for _ in range(60):
|
|
63
|
+
if Path(SOCKET).exists():
|
|
64
|
+
break
|
|
65
|
+
time.sleep(1)
|
|
66
|
+
login = run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset", check=False)
|
|
67
|
+
if login.returncode:
|
|
68
|
+
raise RuntimeError("Tailscale authentication failed; use a valid reusable key or a fresh key for this worker")
|
|
69
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
70
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
71
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
|
+
return dns_name, f"http://{dns_name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def server_is_healthy(api_key: str) -> bool:
|
|
76
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
79
|
+
return response.status < 500
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def wait_for_server(api_key: str, process: subprocess.Popen[bytes]) -> None:
|
|
85
|
+
for _ in range(240):
|
|
86
|
+
if process.poll() is not None:
|
|
87
|
+
log_path = LOG_DIR / "cosyvoice3.log"
|
|
88
|
+
log_tail = log_path.read_text(encoding="utf-8", errors="replace")[-12000:] if log_path.exists() else "log unavailable"
|
|
89
|
+
raise RuntimeError(f"CosyVoice 3 exited with status {process.returncode}:\n{log_tail}")
|
|
90
|
+
if server_is_healthy(api_key):
|
|
91
|
+
return
|
|
92
|
+
time.sleep(2)
|
|
93
|
+
raise RuntimeError("CosyVoice 3 did not become healthy; inspect /content/ciel-speech-logs/cosyvoice3.log")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def prepare_backend() -> None:
|
|
97
|
+
current = BACKEND_MARKER.read_text(encoding="utf-8", errors="replace").strip() if BACKEND_MARKER.exists() else ""
|
|
98
|
+
if current != "cosyvoice3":
|
|
99
|
+
run("bash", "-lc", f"fuser -k {PORT}/tcp >/dev/null 2>&1 || true", check=False)
|
|
100
|
+
time.sleep(2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> None:
|
|
104
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
105
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
106
|
+
run(sys.executable, "-m", "pip", "install", "-U", "nvidia-cuda-runtime==13.0.96", "vllm==0.24.0", "vllm-omni==0.24.0")
|
|
107
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
prepare_backend()
|
|
109
|
+
if not server_is_healthy(api_key):
|
|
110
|
+
command = [
|
|
111
|
+
"vllm", "serve", MODEL, "--omni", "--host", "127.0.0.1", "--port", str(PORT),
|
|
112
|
+
"--trust-remote-code", "--gpu-memory-utilization", "0.72",
|
|
113
|
+
]
|
|
114
|
+
if api_key:
|
|
115
|
+
command.extend(["--api-key", api_key])
|
|
116
|
+
server_log = (LOG_DIR / "cosyvoice3.log").open("ab")
|
|
117
|
+
process = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
118
|
+
wait_for_server(api_key, process)
|
|
119
|
+
BACKEND_MARKER.write_text("cosyvoice3", encoding="utf-8")
|
|
120
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
121
|
+
print(json.dumps({"ok": True, "role": "tts", "hostname": dns_name, "base_url": base_url, "model": MODEL, "backend": "cosyvoice3", "streaming": True, "sample_rate": 24000, "api_key_set": bool(api_key)}, indent=2))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|
|
@@ -22,6 +22,7 @@ PORT = 8091
|
|
|
22
22
|
SOCKET = "/tmp/ciel-tts-tailscaled.sock"
|
|
23
23
|
STATE = "/tmp/ciel-tts-tailscaled.state"
|
|
24
24
|
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
25
|
+
BACKEND_MARKER = Path("/content/ciel-speech-tts-backend")
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def secret(name: str, *, required: bool = False) -> str:
|
|
@@ -109,6 +110,13 @@ def server_is_healthy(api_key: str) -> bool:
|
|
|
109
110
|
return False
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
def prepare_backend() -> None:
|
|
114
|
+
current = BACKEND_MARKER.read_text(encoding="utf-8", errors="replace").strip() if BACKEND_MARKER.exists() else ""
|
|
115
|
+
if current and current != "moss":
|
|
116
|
+
run("bash", "-lc", f"fuser -k {PORT}/tcp >/dev/null 2>&1 || true", check=False)
|
|
117
|
+
time.sleep(2)
|
|
118
|
+
|
|
119
|
+
|
|
112
120
|
def main() -> None:
|
|
113
121
|
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
114
122
|
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
@@ -123,6 +131,7 @@ def main() -> None:
|
|
|
123
131
|
"vllm-omni==0.24.0",
|
|
124
132
|
)
|
|
125
133
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
prepare_backend()
|
|
126
135
|
if not server_is_healthy(api_key):
|
|
127
136
|
command = [
|
|
128
137
|
"vllm-omni", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
|
|
@@ -142,6 +151,7 @@ def main() -> None:
|
|
|
142
151
|
server_log = (LOG_DIR / "moss-tts.log").open("ab")
|
|
143
152
|
process = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True, env=server_env)
|
|
144
153
|
wait_for_server(api_key, process)
|
|
154
|
+
BACKEND_MARKER.write_text("moss", encoding="utf-8")
|
|
145
155
|
dns_name, base_url = start_tailscale(auth_key)
|
|
146
156
|
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
157
|
|
|
@@ -12,6 +12,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
DEFAULT_TTS_REFERENCE_AUDIO = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
|
|
15
|
+
DEFAULT_COSYVOICE_REFERENCE_AUDIO = "https://raw.githubusercontent.com/QwenAudio/CosyVoice/main/asset/zero_shot_prompt.wav"
|
|
16
|
+
DEFAULT_COSYVOICE_REFERENCE_TEXT = "希望你以后能够做的比我还好呦。"
|
|
17
|
+
TTS_BACKENDS = {
|
|
18
|
+
"moss": {"model": "OpenMOSS-Team/MOSS-TTS-Nano", "sample_rate": 48000, "streaming": False},
|
|
19
|
+
"cosyvoice3": {"model": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512", "sample_rate": 24000, "streaming": True},
|
|
20
|
+
}
|
|
15
21
|
DEFAULT_COLAB_SETTINGS: dict[str, Any] = {
|
|
16
22
|
"enabled": True,
|
|
17
23
|
"distribution": "Ubuntu-26.04",
|
|
@@ -21,6 +27,7 @@ DEFAULT_COLAB_SETTINGS: dict[str, Any] = {
|
|
|
21
27
|
"tts_session": "ciel-tts",
|
|
22
28
|
"asr_accelerator": "T4",
|
|
23
29
|
"tts_accelerator": "T4",
|
|
30
|
+
"tts_backend": "moss",
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
|
|
@@ -45,6 +52,7 @@ def configure(
|
|
|
45
52
|
tts_session: str | None = None,
|
|
46
53
|
asr_accelerator: str | None = None,
|
|
47
54
|
tts_accelerator: str | None = None,
|
|
55
|
+
tts_backend: str | None = None,
|
|
48
56
|
) -> dict[str, Any]:
|
|
49
57
|
import ciel_runtime
|
|
50
58
|
|
|
@@ -61,14 +69,24 @@ def configure(
|
|
|
61
69
|
"tts_session": tts_session,
|
|
62
70
|
"asr_accelerator": asr_accelerator,
|
|
63
71
|
"tts_accelerator": tts_accelerator,
|
|
72
|
+
"tts_backend": tts_backend,
|
|
64
73
|
}
|
|
65
74
|
colab.update({key: value for key, value in overrides.items() if value is not None})
|
|
66
75
|
colab["enabled"] = True
|
|
76
|
+
backend = str(colab.get("tts_backend") or "moss").strip().lower()
|
|
77
|
+
if backend not in TTS_BACKENDS:
|
|
78
|
+
raise ValueError(f"unsupported TTS backend: {backend}")
|
|
79
|
+
backend_settings = TTS_BACKENDS[backend]
|
|
67
80
|
speech["colab"] = colab
|
|
68
81
|
asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
|
|
69
|
-
tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"),
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), **backend_settings})
|
|
83
|
+
known_defaults = {DEFAULT_TTS_REFERENCE_AUDIO, DEFAULT_COSYVOICE_REFERENCE_AUDIO, ""}
|
|
84
|
+
current_reference = str(tts.get("ref_audio") or "").strip()
|
|
85
|
+
desired_reference = DEFAULT_COSYVOICE_REFERENCE_AUDIO if backend == "cosyvoice3" else tts_reference_audio
|
|
86
|
+
if desired_reference and current_reference in known_defaults:
|
|
87
|
+
tts["ref_audio"] = desired_reference
|
|
88
|
+
if backend == "cosyvoice3" and current_reference in known_defaults and not str(tts.get("ref_text") or "").strip():
|
|
89
|
+
tts["ref_text"] = DEFAULT_COSYVOICE_REFERENCE_TEXT
|
|
72
90
|
ciel_runtime.save_config(config)
|
|
73
91
|
return {"asr": asr["base_url"], "tts": tts["base_url"], "colab": colab}
|
|
74
92
|
|
|
@@ -85,6 +103,7 @@ def main() -> int:
|
|
|
85
103
|
parser.add_argument("--tts-session")
|
|
86
104
|
parser.add_argument("--asr-accelerator")
|
|
87
105
|
parser.add_argument("--tts-accelerator")
|
|
106
|
+
parser.add_argument("--tts-backend", choices=tuple(TTS_BACKENDS))
|
|
88
107
|
parser.add_argument("--print-colab-settings", action="store_true")
|
|
89
108
|
args = parser.parse_args()
|
|
90
109
|
if args.print_colab_settings:
|
|
@@ -103,6 +122,7 @@ def main() -> int:
|
|
|
103
122
|
tts_session=args.tts_session,
|
|
104
123
|
asr_accelerator=args.asr_accelerator,
|
|
105
124
|
tts_accelerator=args.tts_accelerator,
|
|
125
|
+
tts_backend=args.tts_backend,
|
|
106
126
|
)
|
|
107
127
|
print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
|
|
108
128
|
return 0
|
|
@@ -8,7 +8,8 @@ param(
|
|
|
8
8
|
[string]$AsrSession,
|
|
9
9
|
[string]$TtsSession,
|
|
10
10
|
[string]$AsrAccelerator,
|
|
11
|
-
[string]$TtsAccelerator
|
|
11
|
+
[string]$TtsAccelerator,
|
|
12
|
+
[string]$TtsBackend
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
$ErrorActionPreference = "Stop"
|
|
@@ -25,6 +26,8 @@ if ([string]::IsNullOrWhiteSpace($AsrSession)) { $AsrSession = [string]$settings
|
|
|
25
26
|
if ([string]::IsNullOrWhiteSpace($TtsSession)) { $TtsSession = [string]$settings.tts_session }
|
|
26
27
|
if ([string]::IsNullOrWhiteSpace($AsrAccelerator)) { $AsrAccelerator = [string]$settings.asr_accelerator }
|
|
27
28
|
if ([string]::IsNullOrWhiteSpace($TtsAccelerator)) { $TtsAccelerator = [string]$settings.tts_accelerator }
|
|
29
|
+
if ([string]::IsNullOrWhiteSpace($TtsBackend)) { $TtsBackend = [string]$settings.tts_backend }
|
|
30
|
+
if ([string]::IsNullOrWhiteSpace($TtsBackend)) { $TtsBackend = "moss" }
|
|
28
31
|
if ($Distribution -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid WSL distribution name." }
|
|
29
32
|
if ($ColabAuth -notin @('adc', 'oauth2')) { throw "ColabAuth must be adc or oauth2." }
|
|
30
33
|
if ($Profile -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid Colab account profile name." }
|
|
@@ -34,6 +37,7 @@ foreach ($session in @($AsrSession, $TtsSession)) {
|
|
|
34
37
|
foreach ($accelerator in @($AsrAccelerator, $TtsAccelerator)) {
|
|
35
38
|
if ($accelerator -notin @('T4', 'L4', 'G4', 'A100', 'H100')) { throw "Unsupported Colab accelerator: $accelerator" }
|
|
36
39
|
}
|
|
40
|
+
if ($TtsBackend -notin @('moss', 'cosyvoice3')) { throw "TtsBackend must be moss or cosyvoice3." }
|
|
37
41
|
$wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
|
|
38
42
|
if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
|
|
39
43
|
$wslHome = (& wsl -d $Distribution -- bash -lc 'printf %s "$HOME"').Trim()
|
|
@@ -122,11 +126,12 @@ $asrArguments += @('--file', "$wslRepo/scripts/colab/bootstrap_qwen_asr.py")
|
|
|
122
126
|
$asrOutput = (Invoke-Colab $asrArguments 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
|
|
123
127
|
if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
|
|
124
128
|
|
|
125
|
-
Write-Host "Installing
|
|
129
|
+
Write-Host "Installing $TtsBackend and its Tailscale service..."
|
|
126
130
|
$ttsArguments = @('exec', '--session', $TtsSession)
|
|
127
131
|
if ($env:TAILSCALE_AUTHKEY) { $ttsArguments += @('--env', "TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)") }
|
|
128
132
|
if ($env:CIEL_SPEECH_API_KEY) { $ttsArguments += @('--env', "CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)") }
|
|
129
|
-
$
|
|
133
|
+
$ttsBootstrap = if ($TtsBackend -eq 'cosyvoice3') { 'bootstrap_cosyvoice3.py' } else { 'bootstrap_moss_tts.py' }
|
|
134
|
+
$ttsArguments += @('--file', "$wslRepo/scripts/colab/$ttsBootstrap")
|
|
130
135
|
$ttsOutput = (Invoke-Colab $ttsArguments 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
|
|
131
136
|
if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
|
|
132
137
|
|
|
@@ -140,7 +145,7 @@ function Read-BootstrapResult([string]$Text, [string]$Role) {
|
|
|
140
145
|
|
|
141
146
|
$asr = Read-BootstrapResult $asrOutput "asr"
|
|
142
147
|
$tts = Read-BootstrapResult $ttsOutput "tts"
|
|
143
|
-
& python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url --distribution $Distribution --auth $ColabAuth --profile $Profile --asr-session $AsrSession --tts-session $TtsSession --asr-accelerator $AsrAccelerator --tts-accelerator $TtsAccelerator
|
|
148
|
+
& python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url --distribution $Distribution --auth $ColabAuth --profile $Profile --asr-session $AsrSession --tts-session $TtsSession --asr-accelerator $AsrAccelerator --tts-accelerator $TtsAccelerator --tts-backend $TtsBackend
|
|
144
149
|
if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
|
|
145
150
|
|
|
146
151
|
Write-Host "Both services are running and connected to Web Chat > Speech Settings."
|
|
Binary file
|
|
Binary file
|