Spaces:
Paused
Paused
FIX: auto-start voice typing after AI finishes speaking so user can respond without manual mic click. Also stop orphaned VoiceTyper instances before creating new ones to prevent mic disconnect and double-send.
Browse files- src/app.js +45 -2
src/app.js
CHANGED
|
@@ -272,6 +272,8 @@ let textMode = false;
|
|
| 272 |
let voiceTyper = null;
|
| 273 |
let config = { lb: false, allowDirect: true };
|
| 274 |
let sessionInProgress = false;
|
|
|
|
|
|
|
| 275 |
let autoGreetingSent = false;
|
| 276 |
let preFetchedNews = [];
|
| 277 |
let latestNewsUrl = null;
|
|
@@ -1017,6 +1019,8 @@ function showTextChat(show) {
|
|
| 1017 |
function onVoiceResult(e) {
|
| 1018 |
if (!e || !e.transcript) return;
|
| 1019 |
if (!chatInput) return;
|
|
|
|
|
|
|
| 1020 |
const text = e.transcript.trim();
|
| 1021 |
chatInput.value = text;
|
| 1022 |
if (e.isFinal && sessionInProgress) {
|
|
@@ -1025,6 +1029,10 @@ function onVoiceResult(e) {
|
|
| 1025 |
if (triggerRe.test(text)) {
|
| 1026 |
setCaption("Đang gửi…", "live");
|
| 1027 |
sendTextMessage();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
} else {
|
| 1029 |
setCaption("Nhấp Send để gửi", "live");
|
| 1030 |
}
|
|
@@ -1034,6 +1042,13 @@ function onVoiceResult(e) {
|
|
| 1034 |
/** Start (or restart) Vietnamese voice recognition in text chat mode. */
|
| 1035 |
function startVoiceTyping() {
|
| 1036 |
if (!sessionInProgress || !chatInput) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false });
|
| 1038 |
if (!voiceTyper.init()) {
|
| 1039 |
// Browser may not support Vietnamese locale — try English as fallback
|
|
@@ -1046,6 +1061,13 @@ function startVoiceTyping() {
|
|
| 1046 |
}
|
| 1047 |
voiceTyper.onResult = onVoiceResult;
|
| 1048 |
voiceTyper.onError = (e) => console.warn("[voiceTyper]", e);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
voiceTyper.start();
|
| 1050 |
setCaption("🎤 Listening…", "live");
|
| 1051 |
return true;
|
|
@@ -1055,6 +1077,7 @@ function startVoiceTyping() {
|
|
| 1055 |
function stopVoiceTyping() {
|
| 1056 |
if (voiceTyper && voiceTyper.active) {
|
| 1057 |
voiceTyper.stop();
|
|
|
|
| 1058 |
setCaption("Voice stopped", "live");
|
| 1059 |
}
|
| 1060 |
}
|
|
@@ -1075,6 +1098,11 @@ function sendTextMessage() {
|
|
| 1075 |
const text = chatInput.value.trim();
|
| 1076 |
if (!text) return;
|
| 1077 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
addChatMessage("user", text);
|
| 1079 |
|
| 1080 |
// New topic? re-filter the news list and reset the image sequence.
|
|
@@ -1090,8 +1118,8 @@ function sendTextMessage() {
|
|
| 1090 |
if (!sendTextViaSession(text)) {
|
| 1091 |
setCaption("QUEUED…");
|
| 1092 |
}
|
| 1093 |
-
//
|
| 1094 |
-
|
| 1095 |
}
|
| 1096 |
|
| 1097 |
if (newsCloseBtn) {
|
|
@@ -1363,7 +1391,18 @@ function onStatus(status) {
|
|
| 1363 |
break;
|
| 1364 |
}
|
| 1365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
if (status === "user-speaking") {
|
|
|
|
|
|
|
|
|
|
| 1367 |
subtitles.classList.remove("visible");
|
| 1368 |
showTextChat(textMode);
|
| 1369 |
}
|
|
@@ -1660,6 +1699,10 @@ function _attachClientEvents(c) {
|
|
| 1660 |
}
|
| 1661 |
_topicTagsRendered = false;
|
| 1662 |
try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1663 |
});
|
| 1664 |
|
| 1665 |
c.addEventListener("toolcall", (e) => {
|
|
|
|
| 272 |
let voiceTyper = null;
|
| 273 |
let config = { lb: false, allowDirect: true };
|
| 274 |
let sessionInProgress = false;
|
| 275 |
+
let _lastVoiceText = "";
|
| 276 |
+
let _lastVoiceSendMs = 0;
|
| 277 |
let autoGreetingSent = false;
|
| 278 |
let preFetchedNews = [];
|
| 279 |
let latestNewsUrl = null;
|
|
|
|
| 1019 |
function onVoiceResult(e) {
|
| 1020 |
if (!e || !e.transcript) return;
|
| 1021 |
if (!chatInput) return;
|
| 1022 |
+
// Ignore results when AI is speaking or session not in user-turn
|
| 1023 |
+
if (!sessionInProgress) return;
|
| 1024 |
const text = e.transcript.trim();
|
| 1025 |
chatInput.value = text;
|
| 1026 |
if (e.isFinal && sessionInProgress) {
|
|
|
|
| 1029 |
if (triggerRe.test(text)) {
|
| 1030 |
setCaption("Đang gửi…", "live");
|
| 1031 |
sendTextMessage();
|
| 1032 |
+
// Stop voice typing after auto-send — user must click mic again to speak
|
| 1033 |
+
if (voiceTyper && voiceTyper.active) {
|
| 1034 |
+
voiceTyper.stop();
|
| 1035 |
+
}
|
| 1036 |
} else {
|
| 1037 |
setCaption("Nhấp Send để gửi", "live");
|
| 1038 |
}
|
|
|
|
| 1042 |
/** Start (or restart) Vietnamese voice recognition in text chat mode. */
|
| 1043 |
function startVoiceTyping() {
|
| 1044 |
if (!sessionInProgress || !chatInput) return false;
|
| 1045 |
+
// Don't restart if already listening
|
| 1046 |
+
if (voiceTyper && voiceTyper.active) return true;
|
| 1047 |
+
// FIX: stop any previous VoiceTyper first — orphaned instances steal the mic
|
| 1048 |
+
if (voiceTyper) {
|
| 1049 |
+
voiceTyper.stop();
|
| 1050 |
+
voiceTyper = null;
|
| 1051 |
+
}
|
| 1052 |
voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false });
|
| 1053 |
if (!voiceTyper.init()) {
|
| 1054 |
// Browser may not support Vietnamese locale — try English as fallback
|
|
|
|
| 1061 |
}
|
| 1062 |
voiceTyper.onResult = onVoiceResult;
|
| 1063 |
voiceTyper.onError = (e) => console.warn("[voiceTyper]", e);
|
| 1064 |
+
voiceTyper.onSoundStart = () => setCaption("🎤 Listening…", "live");
|
| 1065 |
+
voiceTyper.onSoundEnd = () => {
|
| 1066 |
+
// Keep listening — only stop when AI speaks or user clicks mic again
|
| 1067 |
+
if (voiceTyper && voiceTyper.active) {
|
| 1068 |
+
// Do nothing; continuous listening is intentional
|
| 1069 |
+
}
|
| 1070 |
+
};
|
| 1071 |
voiceTyper.start();
|
| 1072 |
setCaption("🎤 Listening…", "live");
|
| 1073 |
return true;
|
|
|
|
| 1077 |
function stopVoiceTyping() {
|
| 1078 |
if (voiceTyper && voiceTyper.active) {
|
| 1079 |
voiceTyper.stop();
|
| 1080 |
+
voiceTyper = null;
|
| 1081 |
setCaption("Voice stopped", "live");
|
| 1082 |
}
|
| 1083 |
}
|
|
|
|
| 1098 |
const text = chatInput.value.trim();
|
| 1099 |
if (!text) return;
|
| 1100 |
|
| 1101 |
+
// Dedup: ignore if this exact message was already sent recently
|
| 1102 |
+
if (text === _lastVoiceText && Date.now() - _lastVoiceSendMs < 3000) return;
|
| 1103 |
+
_lastVoiceText = text;
|
| 1104 |
+
_lastVoiceSendMs = Date.now();
|
| 1105 |
+
|
| 1106 |
addChatMessage("user", text);
|
| 1107 |
|
| 1108 |
// New topic? re-filter the news list and reset the image sequence.
|
|
|
|
| 1118 |
if (!sendTextViaSession(text)) {
|
| 1119 |
setCaption("QUEUED…");
|
| 1120 |
}
|
| 1121 |
+
// Voice typing stays active for continuous conversation;
|
| 1122 |
+
// it will auto-stop when the AI starts speaking.
|
| 1123 |
}
|
| 1124 |
|
| 1125 |
if (newsCloseBtn) {
|
|
|
|
| 1391 |
break;
|
| 1392 |
}
|
| 1393 |
|
| 1394 |
+
if (status === "ai-speaking") {
|
| 1395 |
+
// AI is speaking — stop voice typing so mic doesn't pick up audio
|
| 1396 |
+
if (voiceTyper && voiceTyper.active) {
|
| 1397 |
+
voiceTyper.stop();
|
| 1398 |
+
}
|
| 1399 |
+
subtitles.classList.add("visible");
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
if (status === "user-speaking") {
|
| 1403 |
+
// User's turn to speak — auto-start voice typing so user can speak
|
| 1404 |
+
// immediately without clicking the mic button
|
| 1405 |
+
setTimeout(() => startVoiceTyping(), 400);
|
| 1406 |
subtitles.classList.remove("visible");
|
| 1407 |
showTextChat(textMode);
|
| 1408 |
}
|
|
|
|
| 1699 |
}
|
| 1700 |
_topicTagsRendered = false;
|
| 1701 |
try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
|
| 1702 |
+
// FIX: auto-start voice typing after AI finishes speaking so the user can respond immediately
|
| 1703 |
+
if (sessionInProgress) {
|
| 1704 |
+
setTimeout(() => startVoiceTyping(), 500);
|
| 1705 |
+
}
|
| 1706 |
});
|
| 1707 |
|
| 1708 |
c.addEventListener("toolcall", (e) => {
|