@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.
@@ -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
  },
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.12"
96
+ VERSION = "0.2.13"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -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 (optional)<input id="ttsReferenceText" placeholder="Transcript of the reference clip"></label>
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({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}}),
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
- activeSpeechUrl = URL.createObjectURL(blob);
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 = '';
@@ -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 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.
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.12",
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("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
70
- if tts_reference_audio and not str(tts.get("ref_audio") or "").strip():
71
- tts["ref_audio"] = tts_reference_audio
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 MOSS-TTS-Nano and its Tailscale service..."
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
- $ttsArguments += @('--file', "$wslRepo/scripts/colab/bootstrap_moss_tts.py")
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."