Fix chat mic button: getUserMedia before any async call

#10
by bep40 - opened
.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
- // If already listening, stop
1790
- if (voiceTyper && voiceTyper.active) {
1791
- stopVoiceTyping();
1792
- return;
1793
- }
1794
- // Request mic access
1795
- const micStream = await navigator.mediaDevices.getUserMedia({
1796
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
1797
- }).catch(() => null);
1798
- if (!micStream) {
1799
  setCaption("MIC BLOCKED", "error");
1800
  return;
1801
  }
1802
- micStream.getTracks().forEach((t) => t.stop());
1803
- startVoiceTyping();
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
- }