Spaces:
Paused
Paused
Fix chat mic button: getUserMedia before any async call
#10
by bep40 - opened
- .gitattributes +0 -5
- README.md +0 -2
- index.html +1 -14
- public/avatars/lisamy1.glb +0 -3
- public/avatars/scene.glb +0 -3
- public/avatars/vuong1.glb +0 -3
- src/app.js +12 -115
- src/voiceTyper.js +0 -181
.gitattributes
CHANGED
|
@@ -52,8 +52,3 @@ public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text
|
|
| 52 |
public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
|
| 53 |
public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
|
| 54 |
public/avatars/viverse_avatar_model_209370.vrm filter=lfs diff=lfs merge=lfs -text
|
| 55 |
-
public/avatars/scene.glb filter=lfs diff=lfs merge=lfs -text
|
| 56 |
-
public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text
|
| 57 |
-
public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
|
| 58 |
-
public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
|
| 59 |
-
public/avatars/lisamy1.glb filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 52 |
public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
|
| 53 |
public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
|
| 54 |
public/avatars/viverse_avatar_model_209370.vrm filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -6,8 +6,6 @@ colorTo: purple
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
app_port: 7860
|
| 9 |
-
tags:
|
| 10 |
-
- ml-intern
|
| 11 |
---
|
| 12 |
|
| 13 |
# Gemma Multi-Avatar
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
app_port: 7860
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
# Gemma Multi-Avatar
|
index.html
CHANGED
|
@@ -11,19 +11,6 @@
|
|
| 11 |
rel="stylesheet"
|
| 12 |
/>
|
| 13 |
<link rel="stylesheet" href="./src/style.css" />
|
| 14 |
-
<!--
|
| 15 |
-
CRITICAL: Import map for bare module specifiers used in src/avatar.js and src/app.js.
|
| 16 |
-
Without this, browser fails to resolve "three", "@met4citizen/talkinghead", etc.
|
| 17 |
-
-->
|
| 18 |
-
<script type="importmap">
|
| 19 |
-
{
|
| 20 |
-
"imports": {
|
| 21 |
-
"three": "https://esm.sh/three@0.173.0",
|
| 22 |
-
"three/": "https://esm.sh/three@0.173.0/",
|
| 23 |
-
"@met4citizen/talkinghead": "https://esm.sh/@met4citizen/talkinghead@0.6.26"
|
| 24 |
-
}
|
| 25 |
-
}
|
| 26 |
-
</script>
|
| 27 |
</head>
|
| 28 |
<body>
|
| 29 |
<main id="app">
|
|
@@ -175,4 +162,4 @@
|
|
| 175 |
</main>
|
| 176 |
<script type="module" src="./src/app.js"></script>
|
| 177 |
</body>
|
| 178 |
-
</html>
|
|
|
|
| 11 |
rel="stylesheet"
|
| 12 |
/>
|
| 13 |
<link rel="stylesheet" href="./src/style.css" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
<main id="app">
|
|
|
|
| 162 |
</main>
|
| 163 |
<script type="module" src="./src/app.js"></script>
|
| 164 |
</body>
|
| 165 |
+
</html>
|
public/avatars/lisamy1.glb
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f
|
| 3 |
-
size 8320540
|
|
|
|
|
|
|
|
|
|
|
|
public/avatars/scene.glb
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:c88c7c12ee1e978770ed95f1e0ab7e884e2af8c5d368d0ce7c57cbef5a42a47b
|
| 3 |
-
size 7490660
|
|
|
|
|
|
|
|
|
|
|
|
public/avatars/vuong1.glb
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a
|
| 3 |
-
size 6964484
|
|
|
|
|
|
|
|
|
|
|
|
src/app.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
|
| 2 |
import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
|
| 3 |
import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
|
| 4 |
-
import { VoiceTyper } from "./voiceTyper.js";
|
| 5 |
|
| 6 |
const VOICES = [
|
| 7 |
"Aiden", "Ryan", "Dylan", "Eric",
|
|
@@ -269,11 +268,8 @@ let client = null;
|
|
| 269 |
let muted = false;
|
| 270 |
let subtitleTimer = 0;
|
| 271 |
let textMode = false;
|
| 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;
|
|
@@ -1013,73 +1009,7 @@ function showTextChat(show) {
|
|
| 1013 |
url.searchParams.delete("fakemic");
|
| 1014 |
history.replaceState(null, "", url.href);
|
| 1015 |
}
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
/** Handle Vietnamese voice recognition result: fill chat input, auto-send on "Gửi". */
|
| 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) {
|
| 1027 |
-
// Auto-send trigger phrases (Vietnamese + English)
|
| 1028 |
-
const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i;
|
| 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 |
-
}
|
| 1039 |
-
}
|
| 1040 |
-
}
|
| 1041 |
-
|
| 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
|
| 1055 |
-
voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false });
|
| 1056 |
-
if (!voiceTyper.init()) {
|
| 1057 |
-
setCaption("VOICE NOT SUPPORTED", "error");
|
| 1058 |
-
voiceTyper = null;
|
| 1059 |
-
return false;
|
| 1060 |
-
}
|
| 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;
|
| 1074 |
-
}
|
| 1075 |
-
|
| 1076 |
-
/** Stop Vietnamese voice recognition and return to text input. */
|
| 1077 |
-
function stopVoiceTyping() {
|
| 1078 |
-
if (voiceTyper && voiceTyper.active) {
|
| 1079 |
-
voiceTyper.stop();
|
| 1080 |
-
voiceTyper = null;
|
| 1081 |
-
setCaption("Voice stopped", "live");
|
| 1082 |
-
}
|
| 1083 |
}
|
| 1084 |
|
| 1085 |
function sendTextViaSession(text) {
|
|
@@ -1098,12 +1028,8 @@ function sendTextMessage() {
|
|
| 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.
|
| 1109 |
if (client && sessionInProgress) {
|
|
@@ -1118,8 +1044,6 @@ function sendTextMessage() {
|
|
| 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) {
|
|
@@ -1143,10 +1067,6 @@ if (chatCloseBtn) {
|
|
| 1143 |
chatCloseBtn.addEventListener("click", (e) => {
|
| 1144 |
e.stopPropagation();
|
| 1145 |
textMode = false;
|
| 1146 |
-
if (voiceTyper) {
|
| 1147 |
-
voiceTyper.stop();
|
| 1148 |
-
voiceTyper = null;
|
| 1149 |
-
}
|
| 1150 |
showTextChat(false);
|
| 1151 |
textModeBtn.classList.remove("active");
|
| 1152 |
});
|
|
@@ -1391,18 +1311,7 @@ function onStatus(status) {
|
|
| 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,10 +1608,6 @@ function _attachClientEvents(c) {
|
|
| 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) => {
|
|
@@ -1725,12 +1630,6 @@ async function endSession(silent = false) {
|
|
| 1725 |
sessionInProgress = false;
|
| 1726 |
autoGreetingSent = false;
|
| 1727 |
|
| 1728 |
-
// Stop voice recognition if active
|
| 1729 |
-
if (voiceTyper) {
|
| 1730 |
-
voiceTyper.stop();
|
| 1731 |
-
voiceTyper = null;
|
| 1732 |
-
}
|
| 1733 |
-
|
| 1734 |
if (c) {
|
| 1735 |
if (c.options.micStream) {
|
| 1736 |
for (const track of c.options.micStream?.getTracks() ?? []) {
|
|
@@ -1785,22 +1684,20 @@ textModeBtn.addEventListener("click", () => {
|
|
| 1785 |
chatSendBtn.addEventListener("click", () => sendTextMessage());
|
| 1786 |
|
| 1787 |
chatMicBtn.addEventListener("click", async () => {
|
|
|
|
| 1788 |
if (!sessionInProgress) return;
|
| 1789 |
-
//
|
| 1790 |
-
|
| 1791 |
-
|
| 1792 |
-
|
| 1793 |
-
|
| 1794 |
-
|
| 1795 |
-
|
| 1796 |
-
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
| 1797 |
-
}).catch(() => null);
|
| 1798 |
-
if (!micStream) {
|
| 1799 |
setCaption("MIC BLOCKED", "error");
|
| 1800 |
return;
|
| 1801 |
}
|
| 1802 |
-
|
| 1803 |
-
|
| 1804 |
});
|
| 1805 |
|
| 1806 |
chatInput.addEventListener("keypress", (e) => {
|
|
|
|
| 1 |
import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
|
| 2 |
import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
|
| 3 |
import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
|
|
|
|
| 4 |
|
| 5 |
const VOICES = [
|
| 6 |
"Aiden", "Ryan", "Dylan", "Eric",
|
|
|
|
| 268 |
let muted = false;
|
| 269 |
let subtitleTimer = 0;
|
| 270 |
let textMode = false;
|
|
|
|
| 271 |
let config = { lb: false, allowDirect: true };
|
| 272 |
let sessionInProgress = false;
|
|
|
|
|
|
|
| 273 |
let autoGreetingSent = false;
|
| 274 |
let preFetchedNews = [];
|
| 275 |
let latestNewsUrl = null;
|
|
|
|
| 1009 |
url.searchParams.delete("fakemic");
|
| 1010 |
history.replaceState(null, "", url.href);
|
| 1011 |
}
|
| 1012 |
+
if (show) chatInput.focus();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
}
|
| 1014 |
|
| 1015 |
function sendTextViaSession(text) {
|
|
|
|
| 1028 |
const text = chatInput.value.trim();
|
| 1029 |
if (!text) return;
|
| 1030 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1031 |
addChatMessage("user", text);
|
| 1032 |
+
chatInput.value = "";
|
| 1033 |
|
| 1034 |
// New topic? re-filter the news list and reset the image sequence.
|
| 1035 |
if (client && sessionInProgress) {
|
|
|
|
| 1044 |
if (!sendTextViaSession(text)) {
|
| 1045 |
setCaption("QUEUED…");
|
| 1046 |
}
|
|
|
|
|
|
|
| 1047 |
}
|
| 1048 |
|
| 1049 |
if (newsCloseBtn) {
|
|
|
|
| 1067 |
chatCloseBtn.addEventListener("click", (e) => {
|
| 1068 |
e.stopPropagation();
|
| 1069 |
textMode = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
showTextChat(false);
|
| 1071 |
textModeBtn.classList.remove("active");
|
| 1072 |
});
|
|
|
|
| 1311 |
break;
|
| 1312 |
}
|
| 1313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1314 |
if (status === "user-speaking") {
|
|
|
|
|
|
|
|
|
|
| 1315 |
subtitles.classList.remove("visible");
|
| 1316 |
showTextChat(textMode);
|
| 1317 |
}
|
|
|
|
| 1608 |
}
|
| 1609 |
_topicTagsRendered = false;
|
| 1610 |
try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1611 |
});
|
| 1612 |
|
| 1613 |
c.addEventListener("toolcall", (e) => {
|
|
|
|
| 1630 |
sessionInProgress = false;
|
| 1631 |
autoGreetingSent = false;
|
| 1632 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1633 |
if (c) {
|
| 1634 |
if (c.options.micStream) {
|
| 1635 |
for (const track of c.options.micStream?.getTracks() ?? []) {
|
|
|
|
| 1684 |
chatSendBtn.addEventListener("click", () => sendTextMessage());
|
| 1685 |
|
| 1686 |
chatMicBtn.addEventListener("click", async () => {
|
| 1687 |
+
// Switch from text chat to voice chat
|
| 1688 |
if (!sessionInProgress) return;
|
| 1689 |
+
// Request mic stream synchronously (user gesture context) before any async gap
|
| 1690 |
+
let micStream;
|
| 1691 |
+
try {
|
| 1692 |
+
micStream = await navigator.mediaDevices.getUserMedia({
|
| 1693 |
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
| 1694 |
+
});
|
| 1695 |
+
} catch {
|
|
|
|
|
|
|
|
|
|
| 1696 |
setCaption("MIC BLOCKED", "error");
|
| 1697 |
return;
|
| 1698 |
}
|
| 1699 |
+
await endSession(true);
|
| 1700 |
+
void startVoiceSession(micStream);
|
| 1701 |
});
|
| 1702 |
|
| 1703 |
chatInput.addEventListener("keypress", (e) => {
|
src/voiceTyper.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* voiceTyper.js — Vietnamese voice recognition with auto-fill for chat input.
|
| 3 |
-
*
|
| 4 |
-
* Uses the browser's Web Speech API (SpeechRecognition) with vi-VN locale
|
| 5 |
-
* to capture the user's spoken Vietnamese, display the transcript in the
|
| 6 |
-
* chat input field, and optionally auto-submit after speech ends.
|
| 7 |
-
*
|
| 8 |
-
* Events emitted via callbacks:
|
| 9 |
-
* result — { transcript, isFinal, confidence }
|
| 10 |
-
* start — recognition started
|
| 11 |
-
* end — recognition ended (audio stop)
|
| 12 |
-
* error — { message, code }
|
| 13 |
-
* soundstart — microphone capture started
|
| 14 |
-
* soundend — microphone capture ended
|
| 15 |
-
* nomatch — no speech recognised in a segment
|
| 16 |
-
*/
|
| 17 |
-
export class VoiceTyper {
|
| 18 |
-
/**
|
| 19 |
-
* @param {{ lang?: string, continuous?: boolean, interimResults?: boolean, autoSubmit?: boolean, silenceMs?: number }} options
|
| 20 |
-
*/
|
| 21 |
-
constructor(options = {}) {
|
| 22 |
-
this.lang = options.lang || "vi-VN";
|
| 23 |
-
this.continuous = options.continuous ?? true;
|
| 24 |
-
this.interimResults = options.interimResults ?? true;
|
| 25 |
-
this.autoSubmit = options.autoSubmit ?? false;
|
| 26 |
-
this.silenceMs = options.silenceMs ?? 1200;
|
| 27 |
-
this.onResult = null;
|
| 28 |
-
this.onStart = null;
|
| 29 |
-
this.onEnd = null;
|
| 30 |
-
this.onError = null;
|
| 31 |
-
this.onSoundStart = null;
|
| 32 |
-
this.onSoundEnd = null;
|
| 33 |
-
this.onNoMatch = null;
|
| 34 |
-
|
| 35 |
-
this._recognition = null;
|
| 36 |
-
this._active = false;
|
| 37 |
-
this._sessionStartMs = 0;
|
| 38 |
-
this._silenceTimer = null;
|
| 39 |
-
this._lastResultMs = 0;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
get active() {
|
| 43 |
-
return this._active;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
/** Initialise the SpeechRecognition instance. Returns true if supported. */
|
| 47 |
-
init() {
|
| 48 |
-
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 49 |
-
if (!SR) return false;
|
| 50 |
-
|
| 51 |
-
this._recognition = new SR();
|
| 52 |
-
this._recognition.lang = this.lang;
|
| 53 |
-
this._recognition.continuous = this.continuous;
|
| 54 |
-
this._recognition.interimResults = this.interimResults;
|
| 55 |
-
this._recognition.maxAlternatives = 1;
|
| 56 |
-
|
| 57 |
-
this._recognition.addEventListener("result", (e) => this._onResult(e));
|
| 58 |
-
this._recognition.addEventListener("start", () => this._onStart());
|
| 59 |
-
this._recognition.addEventListener("end", () => this._onEnd());
|
| 60 |
-
this._recognition.addEventListener("error", (e) => this._onError(e));
|
| 61 |
-
this._recognition.addEventListener("soundstart", () => this._onSoundStart());
|
| 62 |
-
this._recognition.addEventListener("soundend", () => this._onSoundEnd());
|
| 63 |
-
this._recognition.addEventListener("nomatch", () => this._onNoMatch());
|
| 64 |
-
|
| 65 |
-
return true;
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
/** Start listening. */
|
| 69 |
-
start() {
|
| 70 |
-
if (!this._recognition) return;
|
| 71 |
-
if (this._active) return;
|
| 72 |
-
try {
|
| 73 |
-
this._sessionStartMs = Date.now();
|
| 74 |
-
this._lastResultMs = this._sessionStartMs;
|
| 75 |
-
this._recognition.start();
|
| 76 |
-
this._active = true;
|
| 77 |
-
} catch (e) {
|
| 78 |
-
this._active = false;
|
| 79 |
-
this._emitError("start-failed", e.message);
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
/** Stop listening. */
|
| 84 |
-
stop() {
|
| 85 |
-
if (!this._recognition) return;
|
| 86 |
-
if (!this._active) return;
|
| 87 |
-
try {
|
| 88 |
-
this._recognition.stop();
|
| 89 |
-
} catch (_) {
|
| 90 |
-
/* already stopped */
|
| 91 |
-
}
|
| 92 |
-
this._active = false;
|
| 93 |
-
this._clearSilenceTimer();
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
/** Abort the current session and release resources. */
|
| 97 |
-
abort() {
|
| 98 |
-
this.stop();
|
| 99 |
-
this._recognition = null;
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
/* ── internals ──────────────────────────────────────────── */
|
| 103 |
-
|
| 104 |
-
_onResult(e) {
|
| 105 |
-
let transcript = "";
|
| 106 |
-
let isFinal = true;
|
| 107 |
-
|
| 108 |
-
for (let i = e.resultIndex; i < e.results.length; i++) {
|
| 109 |
-
const alt = e.results[i][0];
|
| 110 |
-
transcript += alt.transcript;
|
| 111 |
-
if (!e.results[i].isFinal) isFinal = false;
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
this._lastResultMs = Date.now();
|
| 115 |
-
this._clearSilenceTimer();
|
| 116 |
-
|
| 117 |
-
if (this.onResult) {
|
| 118 |
-
this.onResult({
|
| 119 |
-
transcript,
|
| 120 |
-
isFinal,
|
| 121 |
-
confidence: e.results[e.results.length - 1]?.[0]?.confidence ?? 1,
|
| 122 |
-
});
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
if (this.autoSubmit && isFinal) {
|
| 126 |
-
this._resetSilenceTimer();
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
_onStart() {
|
| 131 |
-
if (this.onStart) this.onStart();
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
_onEnd() {
|
| 135 |
-
this._clearSilenceTimer();
|
| 136 |
-
const wasActive = this._active;
|
| 137 |
-
this._active = false;
|
| 138 |
-
if (this.onEnd) this.onEnd();
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
_onError(e) {
|
| 142 |
-
this._active = false;
|
| 143 |
-
this._clearSilenceTimer();
|
| 144 |
-
if (this.onError) {
|
| 145 |
-
this.onError({ message: e.error, code: e.error });
|
| 146 |
-
}
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
_onSoundStart() {
|
| 150 |
-
if (this.onSoundStart) this.onSoundStart();
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
_onSoundEnd() {
|
| 154 |
-
if (this.onSoundEnd) this.onSoundEnd();
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
_onNoMatch() {
|
| 158 |
-
if (this.onNoMatch) this.onNoMatch();
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
_emitError(code, message) {
|
| 162 |
-
if (this.onError) this.onError({ message, code });
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
_resetSilenceTimer() {
|
| 166 |
-
this._clearSilenceTimer();
|
| 167 |
-
if (!this.autoSubmit || !this.onResult) return;
|
| 168 |
-
this._silenceTimer = setTimeout(() => {
|
| 169 |
-
if (this._active) {
|
| 170 |
-
this.onResult({ transcript: "", isFinal: true, confidence: 0 });
|
| 171 |
-
}
|
| 172 |
-
}, this.silenceMs);
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
_clearSilenceTimer() {
|
| 176 |
-
if (this._silenceTimer) {
|
| 177 |
-
clearTimeout(this._silenceTimer);
|
| 178 |
-
this._silenceTimer = null;
|
| 179 |
-
}
|
| 180 |
-
}
|
| 181 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|