@oneciel-ai/ciel-runtime 0.2.8 → 0.2.9
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 +7 -1
- package/ciel_runtime_support/web_ui.py +172 -67
- package/docs/COLAB_SPEECH.md +4 -0
- 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
|
@@ -42,6 +42,9 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
|
|
|
42
42
|
"endpoint": "/v1/audio/transcriptions",
|
|
43
43
|
"model": "Qwen/Qwen3-ASR-0.6B",
|
|
44
44
|
"language": "auto",
|
|
45
|
+
"silence_ms": 900,
|
|
46
|
+
"min_speech_ms": 300,
|
|
47
|
+
"vad_threshold": 0.018,
|
|
45
48
|
"api_key": "",
|
|
46
49
|
"timeout_seconds": 300,
|
|
47
50
|
},
|
|
@@ -167,7 +167,7 @@ class SpeechHttpController:
|
|
|
167
167
|
@staticmethod
|
|
168
168
|
def _validated_value(service: str, key: str, value: Any) -> Any:
|
|
169
169
|
allowed = {
|
|
170
|
-
"asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
|
|
170
|
+
"asr": {"enabled", "base_url", "endpoint", "model", "language", "silence_ms", "min_speech_ms", "vad_threshold", "api_key", "timeout_seconds"},
|
|
171
171
|
"tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
|
|
172
172
|
}
|
|
173
173
|
if key not in allowed[service]:
|
|
@@ -178,6 +178,12 @@ class SpeechHttpController:
|
|
|
178
178
|
return max(1, min(3600, int(value)))
|
|
179
179
|
if key == "speed":
|
|
180
180
|
return max(0.25, min(4.0, float(value)))
|
|
181
|
+
if key == "silence_ms":
|
|
182
|
+
return max(250, min(3000, int(value)))
|
|
183
|
+
if key == "min_speech_ms":
|
|
184
|
+
return max(100, min(2000, int(value)))
|
|
185
|
+
if key == "vad_threshold":
|
|
186
|
+
return max(0.005, min(0.2, float(value)))
|
|
181
187
|
text = str(value or "").strip()
|
|
182
188
|
if key == "ref_audio":
|
|
183
189
|
if len(text) > 14_000_000:
|
|
@@ -192,12 +192,12 @@ def render_web_chat_page(
|
|
|
192
192
|
<button class="primary" id="sendButton" type="submit">Send</button>
|
|
193
193
|
</div>
|
|
194
194
|
<div class="composer-actions">
|
|
195
|
-
<button class="attach-button" id="micButton" type="button">Start voice
|
|
195
|
+
<button class="attach-button" id="micButton" type="button">Start live voice</button>
|
|
196
196
|
<button class="attach-button" id="attachButton" type="button">Attach files</button>
|
|
197
197
|
<input id="fileInput" type="file" multiple>
|
|
198
198
|
<div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
|
|
199
199
|
</div>
|
|
200
|
-
<div class="hint">Enter sends. Shift+Enter inserts a new line. The active coding-agent session handles the message, so its configured tools and MCP servers remain available
|
|
200
|
+
<div class="hint">Enter sends. Shift+Enter inserts a new line. Live voice detects the end of each utterance, transcribes and sends it automatically, and supports interruption while TTS is speaking. The active coding-agent session handles the message, so its configured tools and MCP servers remain available.</div>
|
|
201
201
|
</form>
|
|
202
202
|
</main>
|
|
203
203
|
</div>
|
|
@@ -210,6 +210,9 @@ def render_web_chat_page(
|
|
|
210
210
|
<div class="settings-grid">
|
|
211
211
|
<label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
|
|
212
212
|
<label>Language<input id="asrLanguage" placeholder="auto"></label>
|
|
213
|
+
<label>End silence (ms)<input id="asrSilenceMs" type="number" min="250" max="3000" step="50"></label>
|
|
214
|
+
<label>Minimum speech (ms)<input id="asrMinSpeechMs" type="number" min="100" max="2000" step="50"></label>
|
|
215
|
+
<label>VAD threshold<input id="asrVadThreshold" type="number" min="0.005" max="0.2" step="0.001"></label>
|
|
213
216
|
<label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
|
|
214
217
|
<label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
|
|
215
218
|
<label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
@@ -300,14 +303,22 @@ def render_web_chat_page(
|
|
|
300
303
|
let eventSource = null;
|
|
301
304
|
let selectedFiles = [];
|
|
302
305
|
let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
|
|
303
|
-
let mediaRecorder = null;
|
|
304
306
|
let mediaStream = null;
|
|
305
|
-
let recordingChunks = [];
|
|
306
307
|
let audioContext = null;
|
|
307
308
|
let audioInput = null;
|
|
308
309
|
let audioProcessor = null;
|
|
309
|
-
let
|
|
310
|
-
let
|
|
310
|
+
let liveVoiceEnabled = false;
|
|
311
|
+
let vadSpeechActive = false;
|
|
312
|
+
let vadSpeechChunks = [];
|
|
313
|
+
let vadPreRollChunks = [];
|
|
314
|
+
let vadSpeechStartedAt = 0;
|
|
315
|
+
let vadLastVoiceAt = 0;
|
|
316
|
+
let vadVoicedSamples = 0;
|
|
317
|
+
let vadNoiseFloor = 0.006;
|
|
318
|
+
let liveTranscriptionQueue = Promise.resolve();
|
|
319
|
+
let activeSpeechAudio = null;
|
|
320
|
+
let activeSpeechUrl = '';
|
|
321
|
+
let speechGenerationController = null;
|
|
311
322
|
let pendingTtsReferenceAudio = '';
|
|
312
323
|
function setState(text, cls = '') {{
|
|
313
324
|
statePill.textContent = text;
|
|
@@ -527,7 +538,7 @@ def render_web_chat_page(
|
|
|
527
538
|
addBubble(roleForMessage(message), text, mode, message.id);
|
|
528
539
|
if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
|
|
529
540
|
setState('reply received', 'ok');
|
|
530
|
-
if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
|
|
541
|
+
if (speechConfig.tts && speechConfig.tts.enabled && (speechConfig.tts.auto_speak || liveVoiceEnabled)) speakText(text);
|
|
531
542
|
}}
|
|
532
543
|
}}
|
|
533
544
|
function formatBytes(bytes) {{
|
|
@@ -592,6 +603,9 @@ def render_web_chat_page(
|
|
|
592
603
|
document.getElementById('asrBaseUrl').value = asr.base_url || '';
|
|
593
604
|
document.getElementById('asrModel').value = asr.model || '';
|
|
594
605
|
document.getElementById('asrLanguage').value = asr.language || 'auto';
|
|
606
|
+
document.getElementById('asrSilenceMs').value = asr.silence_ms || 900;
|
|
607
|
+
document.getElementById('asrMinSpeechMs').value = asr.min_speech_ms || 300;
|
|
608
|
+
document.getElementById('asrVadThreshold').value = asr.vad_threshold || 0.018;
|
|
595
609
|
document.getElementById('asrApiKey').value = '';
|
|
596
610
|
document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
597
611
|
document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
|
|
@@ -618,7 +632,7 @@ def render_web_chat_page(
|
|
|
618
632
|
document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
|
|
619
633
|
document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
|
|
620
634
|
micButton.disabled = !asr.enabled;
|
|
621
|
-
micButton.title = asr.enabled ? '
|
|
635
|
+
micButton.title = asr.enabled ? 'Continuously detect, transcribe, and send speech' : 'Enable STT in Speech Settings first';
|
|
622
636
|
}}
|
|
623
637
|
async function loadSpeechConfig() {{
|
|
624
638
|
const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
|
|
@@ -635,6 +649,9 @@ def render_web_chat_page(
|
|
|
635
649
|
base_url: document.getElementById('asrBaseUrl').value,
|
|
636
650
|
model: document.getElementById('asrModel').value,
|
|
637
651
|
language: document.getElementById('asrLanguage').value,
|
|
652
|
+
silence_ms: Number(document.getElementById('asrSilenceMs').value || 900),
|
|
653
|
+
min_speech_ms: Number(document.getElementById('asrMinSpeechMs').value || 300),
|
|
654
|
+
vad_threshold: Number(document.getElementById('asrVadThreshold').value || 0.018),
|
|
638
655
|
api_key: document.getElementById('asrApiKey').value,
|
|
639
656
|
}},
|
|
640
657
|
tts: {{
|
|
@@ -671,38 +688,67 @@ def render_web_chat_page(
|
|
|
671
688
|
setSpeechForm(data);
|
|
672
689
|
return data;
|
|
673
690
|
}}
|
|
691
|
+
function stopActiveSpeech() {{
|
|
692
|
+
if (speechGenerationController) speechGenerationController.abort();
|
|
693
|
+
speechGenerationController = null;
|
|
694
|
+
if (activeSpeechAudio) {{
|
|
695
|
+
activeSpeechAudio.pause();
|
|
696
|
+
activeSpeechAudio.currentTime = 0;
|
|
697
|
+
}}
|
|
698
|
+
activeSpeechAudio = null;
|
|
699
|
+
if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
|
|
700
|
+
activeSpeechUrl = '';
|
|
701
|
+
}}
|
|
674
702
|
async function speakText(text) {{
|
|
675
703
|
if (!speechConfig.tts || !speechConfig.tts.enabled) {{
|
|
676
704
|
setState('TTS disabled', 'error');
|
|
677
705
|
return;
|
|
678
706
|
}}
|
|
707
|
+
if (liveVoiceEnabled && vadSpeechActive) return;
|
|
708
|
+
stopActiveSpeech();
|
|
709
|
+
const controller = new AbortController();
|
|
710
|
+
speechGenerationController = controller;
|
|
679
711
|
try {{
|
|
680
712
|
setState('generating speech');
|
|
681
713
|
const response = await fetch('/v1/audio/speech', {{
|
|
682
714
|
method: 'POST',
|
|
683
715
|
headers: {{'content-type': 'application/json'}},
|
|
684
|
-
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'}})
|
|
716
|
+
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'}}),
|
|
717
|
+
signal: controller.signal,
|
|
685
718
|
}});
|
|
686
719
|
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
687
720
|
const blob = await response.blob();
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
721
|
+
if (controller.signal.aborted) return;
|
|
722
|
+
speechGenerationController = null;
|
|
723
|
+
activeSpeechUrl = URL.createObjectURL(blob);
|
|
724
|
+
activeSpeechAudio = new Audio(activeSpeechUrl);
|
|
725
|
+
const audio = activeSpeechAudio;
|
|
726
|
+
const cleanup = () => {{
|
|
727
|
+
if (activeSpeechAudio === audio) {{
|
|
728
|
+
activeSpeechAudio = null;
|
|
729
|
+
if (activeSpeechUrl) URL.revokeObjectURL(activeSpeechUrl);
|
|
730
|
+
activeSpeechUrl = '';
|
|
731
|
+
if (liveVoiceEnabled) setState('listening', 'ok');
|
|
732
|
+
}}
|
|
733
|
+
}};
|
|
734
|
+
audio.addEventListener('ended', cleanup, {{once: true}});
|
|
735
|
+
audio.addEventListener('error', cleanup, {{once: true}});
|
|
692
736
|
await audio.play();
|
|
693
737
|
setState('speaking', 'ok');
|
|
694
738
|
}} catch (err) {{
|
|
739
|
+
if (err && err.name === 'AbortError') return;
|
|
740
|
+
speechGenerationController = null;
|
|
695
741
|
setState('TTS error', 'error');
|
|
696
742
|
addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
|
|
697
743
|
}}
|
|
698
744
|
}}
|
|
699
|
-
async function transcribeRecording(blob) {{
|
|
745
|
+
async function transcribeRecording(blob, populatePrompt = true) {{
|
|
700
746
|
setState('transcribing');
|
|
701
747
|
const audio_base64 = await fileToBase64(blob);
|
|
702
748
|
const response = await fetch('/v1/audio/transcriptions', {{
|
|
703
749
|
method: 'POST',
|
|
704
750
|
headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
|
|
705
|
-
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.
|
|
751
|
+
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.wav', content_type: blob.type || 'audio/wav', model: speechConfig.asr.model, language: speechConfig.asr.language}})
|
|
706
752
|
}});
|
|
707
753
|
const text = await response.text();
|
|
708
754
|
let data = {{}};
|
|
@@ -710,9 +756,12 @@ def render_web_chat_page(
|
|
|
710
756
|
if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
|
|
711
757
|
const transcriptText = String(data.text || data.transcript || '').trim();
|
|
712
758
|
if (!transcriptText) throw new Error('ASR returned no transcript');
|
|
713
|
-
|
|
714
|
-
|
|
759
|
+
if (populatePrompt) {{
|
|
760
|
+
prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
|
|
761
|
+
prompt.focus();
|
|
762
|
+
}}
|
|
715
763
|
setState('transcribed', 'ok');
|
|
764
|
+
return transcriptText;
|
|
716
765
|
}}
|
|
717
766
|
function encodePcmWav(chunks, sampleRate) {{
|
|
718
767
|
const sampleCount = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
@@ -742,63 +791,119 @@ def render_web_chat_page(
|
|
|
742
791
|
}}));
|
|
743
792
|
return new Blob([buffer], {{type: 'audio/wav'}});
|
|
744
793
|
}}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
794
|
+
function resetVadUtterance() {{
|
|
795
|
+
vadSpeechActive = false;
|
|
796
|
+
vadSpeechChunks = [];
|
|
797
|
+
vadSpeechStartedAt = 0;
|
|
798
|
+
vadLastVoiceAt = 0;
|
|
799
|
+
vadVoicedSamples = 0;
|
|
800
|
+
}}
|
|
801
|
+
function queueLiveUtterance(chunks, sampleRate) {{
|
|
802
|
+
if (!chunks.length) return;
|
|
803
|
+
const blob = encodePcmWav(chunks, sampleRate);
|
|
804
|
+
liveTranscriptionQueue = liveTranscriptionQueue.then(async () => {{
|
|
805
|
+
const transcriptText = await transcribeRecording(blob, false);
|
|
806
|
+
setState('sending voice');
|
|
807
|
+
await sendMessage(transcriptText, []);
|
|
808
|
+
if (liveVoiceEnabled) setState('listening', 'ok');
|
|
809
|
+
}}).catch(err => {{
|
|
810
|
+
setState('STT error', 'error');
|
|
811
|
+
addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err));
|
|
812
|
+
}});
|
|
813
|
+
}}
|
|
814
|
+
function finishVadUtterance() {{
|
|
815
|
+
const chunks = vadSpeechChunks.slice();
|
|
816
|
+
const sampleRate = audioContext ? audioContext.sampleRate : 48000;
|
|
817
|
+
resetVadUtterance();
|
|
818
|
+
vadPreRollChunks = [];
|
|
819
|
+
queueLiveUtterance(chunks, sampleRate);
|
|
820
|
+
}}
|
|
821
|
+
function processVadFrame(event) {{
|
|
822
|
+
if (!liveVoiceEnabled || !audioContext) return;
|
|
823
|
+
const chunk = new Float32Array(event.inputBuffer.getChannelData(0));
|
|
824
|
+
let sumSquares = 0;
|
|
825
|
+
for (let index = 0; index < chunk.length; index += 1) sumSquares += chunk[index] * chunk[index];
|
|
826
|
+
const rms = Math.sqrt(sumSquares / Math.max(1, chunk.length));
|
|
827
|
+
const configuredThreshold = Number((speechConfig.asr && speechConfig.asr.vad_threshold) || 0.018);
|
|
828
|
+
const threshold = Math.max(configuredThreshold, Math.min(0.08, vadNoiseFloor * 2.8));
|
|
829
|
+
const voiceDetected = rms >= threshold;
|
|
830
|
+
const now = performance.now();
|
|
831
|
+
if (!vadSpeechActive) {{
|
|
832
|
+
if (!voiceDetected) {{
|
|
833
|
+
vadNoiseFloor = Math.max(0.002, Math.min(0.03, vadNoiseFloor * 0.98 + rms * 0.02));
|
|
834
|
+
vadPreRollChunks.push(chunk);
|
|
835
|
+
const maxPreRollSamples = audioContext.sampleRate * 0.25;
|
|
836
|
+
let preRollSamples = vadPreRollChunks.reduce((total, item) => total + item.length, 0);
|
|
837
|
+
while (preRollSamples > maxPreRollSamples && vadPreRollChunks.length > 1) preRollSamples -= vadPreRollChunks.shift().length;
|
|
838
|
+
return;
|
|
839
|
+
}}
|
|
840
|
+
vadSpeechActive = true;
|
|
841
|
+
vadSpeechStartedAt = now;
|
|
842
|
+
vadLastVoiceAt = now;
|
|
843
|
+
vadVoicedSamples = chunk.length;
|
|
844
|
+
vadSpeechChunks = vadPreRollChunks.concat([chunk]);
|
|
845
|
+
vadPreRollChunks = [];
|
|
846
|
+
stopActiveSpeech();
|
|
847
|
+
setState('hearing speech', 'ok');
|
|
848
|
+
return;
|
|
849
|
+
}}
|
|
850
|
+
vadSpeechChunks.push(chunk);
|
|
851
|
+
if (voiceDetected) {{
|
|
852
|
+
vadLastVoiceAt = now;
|
|
853
|
+
vadVoicedSamples += chunk.length;
|
|
854
|
+
}}
|
|
855
|
+
const silenceMs = Number((speechConfig.asr && speechConfig.asr.silence_ms) || 900);
|
|
856
|
+
const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
|
|
857
|
+
const voicedMs = vadVoicedSamples * 1000 / audioContext.sampleRate;
|
|
858
|
+
const utteranceMs = now - vadSpeechStartedAt;
|
|
859
|
+
if (now - vadLastVoiceAt >= silenceMs) {{
|
|
860
|
+
if (voicedMs >= minSpeechMs) finishVadUtterance();
|
|
861
|
+
else resetVadUtterance();
|
|
862
|
+
}} else if (utteranceMs >= 30000) {{
|
|
863
|
+
finishVadUtterance();
|
|
864
|
+
}}
|
|
751
865
|
}}
|
|
752
866
|
async function startVoiceInput() {{
|
|
753
867
|
if (!navigator.mediaDevices) throw new Error('This browser does not support microphone recording');
|
|
754
|
-
mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
|
|
755
868
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
|
756
|
-
if (AudioContextClass)
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
|
|
771
|
-
await finishVoiceInput(blob);
|
|
772
|
-
}}, {{once: true}});
|
|
773
|
-
mediaRecorder.start();
|
|
774
|
-
}} else {{
|
|
775
|
-
mediaStream.getTracks().forEach(track => track.stop());
|
|
776
|
-
mediaStream = null;
|
|
777
|
-
throw new Error('This browser does not support microphone recording');
|
|
778
|
-
}}
|
|
779
|
-
voiceRecording = true;
|
|
780
|
-
micButton.textContent = 'Stop and transcribe';
|
|
869
|
+
if (!AudioContextClass) throw new Error('Live voice requires Web Audio support');
|
|
870
|
+
mediaStream = await navigator.mediaDevices.getUserMedia({{audio: {{echoCancellation: true, noiseSuppression: true, autoGainControl: true}}}});
|
|
871
|
+
audioContext = new AudioContextClass();
|
|
872
|
+
if (audioContext.state === 'suspended') await audioContext.resume();
|
|
873
|
+
audioInput = audioContext.createMediaStreamSource(mediaStream);
|
|
874
|
+
audioProcessor = audioContext.createScriptProcessor(2048, 1, 1);
|
|
875
|
+
resetVadUtterance();
|
|
876
|
+
vadPreRollChunks = [];
|
|
877
|
+
vadNoiseFloor = 0.006;
|
|
878
|
+
liveVoiceEnabled = true;
|
|
879
|
+
audioProcessor.onaudioprocess = processVadFrame;
|
|
880
|
+
audioInput.connect(audioProcessor);
|
|
881
|
+
audioProcessor.connect(audioContext.destination);
|
|
882
|
+
micButton.textContent = 'Stop live voice';
|
|
781
883
|
micButton.classList.add('recording');
|
|
782
|
-
setState('
|
|
884
|
+
setState('listening', 'ok');
|
|
783
885
|
}}
|
|
784
886
|
async function stopVoiceInput() {{
|
|
785
|
-
if (!
|
|
786
|
-
|
|
787
|
-
if (
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
audioInput.disconnect();
|
|
791
|
-
audioProcessor.disconnect();
|
|
792
|
-
await audioContext.close();
|
|
793
|
-
const blob = encodePcmWav(pcmChunks, sampleRate);
|
|
794
|
-
audioContext = null;
|
|
795
|
-
audioInput = null;
|
|
796
|
-
audioProcessor = null;
|
|
797
|
-
pcmChunks = [];
|
|
798
|
-
await finishVoiceInput(blob);
|
|
799
|
-
}} else if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
800
|
-
mediaRecorder.stop();
|
|
887
|
+
if (!liveVoiceEnabled) return;
|
|
888
|
+
liveVoiceEnabled = false;
|
|
889
|
+
if (vadSpeechActive && audioContext) {{
|
|
890
|
+
const minSpeechMs = Number((speechConfig.asr && speechConfig.asr.min_speech_ms) || 300);
|
|
891
|
+
if (vadVoicedSamples * 1000 / audioContext.sampleRate >= minSpeechMs) finishVadUtterance();
|
|
801
892
|
}}
|
|
893
|
+
resetVadUtterance();
|
|
894
|
+
vadPreRollChunks = [];
|
|
895
|
+
if (audioProcessor) {{ audioProcessor.onaudioprocess = null; audioProcessor.disconnect(); }}
|
|
896
|
+
if (audioInput) audioInput.disconnect();
|
|
897
|
+
if (audioContext) await audioContext.close();
|
|
898
|
+
if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
|
|
899
|
+
audioProcessor = null;
|
|
900
|
+
audioInput = null;
|
|
901
|
+
audioContext = null;
|
|
902
|
+
mediaStream = null;
|
|
903
|
+
stopActiveSpeech();
|
|
904
|
+
micButton.textContent = 'Start live voice';
|
|
905
|
+
micButton.classList.remove('recording');
|
|
906
|
+
setState('ready');
|
|
802
907
|
}}
|
|
803
908
|
async function uploadAttachment(file) {{
|
|
804
909
|
const content = await fileToBase64(file);
|
|
@@ -993,7 +1098,7 @@ def render_web_chat_page(
|
|
|
993
1098
|
}});
|
|
994
1099
|
attachButton.addEventListener('click', () => fileInput.click());
|
|
995
1100
|
micButton.addEventListener('click', async () => {{
|
|
996
|
-
if (
|
|
1101
|
+
if (liveVoiceEnabled) {{
|
|
997
1102
|
await stopVoiceInput();
|
|
998
1103
|
return;
|
|
999
1104
|
}}
|
package/docs/COLAB_SPEECH.md
CHANGED
|
@@ -24,6 +24,10 @@ MOSS-TTS-Nano is a voice-cloning model without built-in speakers. Deployment con
|
|
|
24
24
|
|
|
25
25
|
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.
|
|
26
26
|
|
|
27
|
+
## Live voice
|
|
28
|
+
|
|
29
|
+
Web Chat's **Start live voice** button keeps the microphone open and uses browser-side voice activity detection (VAD). A completed utterance is encoded as PCM WAV, transcribed, and sent to the active coding-agent session automatically. While MOSS TTS is generating or playing a reply, new speech stops it immediately and starts a new utterance (barge-in). Tune end-of-speech silence, minimum speech duration, and the VAD threshold in Speech Settings. This is turn-based low-latency voice for the Colab HTTP workers; token-level streaming ASR/TTS requires workers with streaming protocols.
|
|
30
|
+
|
|
27
31
|
## API surface
|
|
28
32
|
|
|
29
33
|
- `GET|POST /ca/speech/config`
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|