@mooncompany/uplink-chat 0.5.0

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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,883 @@
1
+ // ============================================
2
+ // VOICE MODULE
3
+ // Voice recording, 3D moon, audio playback
4
+ // ============================================
5
+
6
+ // Check for real-time voice support (loaded by realtime-voice.js)
7
+ // window.UplinkRealtime = { start(), stop(), isActive() }
8
+
9
+ // DOM elements
10
+ let voiceBtn, voiceStatus, voiceTimer, moonCanvas;
11
+
12
+ // Three.js objects
13
+ let moonScene, moonCamera, moonRenderer, moonModel, haloMesh;
14
+
15
+ // State
16
+ let stream = null;
17
+ let recorder = null;
18
+ let chunks = [];
19
+ let recordingStart = null;
20
+ let timerInterval = null;
21
+ let maxDurationTimeout = null; // M-17: timeout for max recording duration
22
+ let isRecording = false;
23
+ let isTransitioning = false;
24
+ let haloOpacity = 0;
25
+ let moonAnimationRunning = false;
26
+
27
+ // Auto-hold mode
28
+ let autoHoldMode = false;
29
+ let lastTapTime = 0;
30
+ let silenceStart = null;
31
+ let audioContext = null;
32
+ let analyser = null;
33
+ let monitorInterval = null;
34
+
35
+ // Animation frame timing
36
+ let lastFrameTime = 0;
37
+ let frameInterval = 1000 / 60; // Default 60fps
38
+ let moonObserver = null;
39
+
40
+ // Module init retry state
41
+ let initRetryCount = 0;
42
+ const MAX_INIT_RETRIES = 10;
43
+
44
+ // Constants - Timing
45
+ const INIT_RETRY_DELAY_MS = 100;
46
+ const DOUBLE_TAP_DELAY_MS = 300;
47
+ const AUTO_HOLD_SILENCE_DURATION_MS = 2000;
48
+ const AUDIO_MONITOR_INTERVAL_MS = 100;
49
+ const HOLD_DETECTION_DELAY_MS = 160;
50
+ const TAP_VS_HOLD_THRESHOLD_MS = 150;
51
+ const MIN_RECORDING_DURATION_MS = 500;
52
+ const MAX_RECORDING_DURATION_MS = 300000; // 5 minutes max recording (M-17)
53
+ const TIMER_UPDATE_INTERVAL_MS = 1000;
54
+ const MEDIA_RECORDER_TIMESLICE_MS = 100;
55
+
56
+ // Constants - Audio
57
+ const SILENCE_THRESHOLD = 12;
58
+
59
+ // Constants - 3D Moon
60
+ const MOON_CANVAS_SIZE = 80;
61
+ const MOON_CAMERA_FOV = 50;
62
+ const MOON_CAMERA_NEAR = 0.1;
63
+ const MOON_CAMERA_FAR = 1000;
64
+ const MOON_CAMERA_Z = 3.5;
65
+ const MOON_SPHERE_SEGMENTS = 64;
66
+ const MOON_INITIAL_TILT = 0.2;
67
+ const BASE_ROTATION_SPEED = 0.003;
68
+ const RECORDING_ROTATION_SPEED = 0.015;
69
+ const REALTIME_ROTATION_SPEED = 0.04;
70
+
71
+ // Constants - Halo animation
72
+ const HALO_INNER_RADIUS = 1.05;
73
+ const HALO_OUTER_RADIUS = 1.5;
74
+ const HALO_RECORDING_OPACITY = 0.7;
75
+ const HALO_REALTIME_OPACITY = 0.9;
76
+ const HALO_OPACITY_LERP = 0.25;
77
+ const HALO_ROTATION_SPEED = 0.02;
78
+ const HALO_PULSE_SPEED = 0.006;
79
+ const HALO_PULSE_AMPLITUDE = 0.25;
80
+ const HALO_PULSE_BASE = 0.6;
81
+ const HALO_SCALE_SPEED = 0.004;
82
+ const HALO_SCALE_AMPLITUDE = 0.05;
83
+
84
+ function init() {
85
+ voiceBtn = document.getElementById('voiceBtn');
86
+ voiceStatus = document.getElementById('voiceStatus');
87
+ voiceTimer = document.getElementById('voiceTimer');
88
+ moonCanvas = document.getElementById('moonCanvas');
89
+
90
+ if (!voiceBtn) {
91
+ if (initRetryCount >= MAX_INIT_RETRIES) {
92
+ logger.error('Voice: Elements not found after max retries, giving up');
93
+ return;
94
+ }
95
+ initRetryCount++;
96
+ const delay = Math.min(INIT_RETRY_DELAY_MS * Math.pow(2, initRetryCount), 5000);
97
+ logger.warn(`Voice: Elements not found, retrying (${initRetryCount}/${MAX_INIT_RETRIES})...`);
98
+ setTimeout(init, delay);
99
+ return;
100
+ }
101
+
102
+ // Set initial ARIA label for accessibility
103
+ voiceBtn.setAttribute('aria-label', 'Start recording');
104
+
105
+ // Defer 3D moon initialization until voice mode is first shown.
106
+ // WebGL renderers can produce zero-sized contexts when the canvas
107
+ // parent has display:none (voice row is hidden by default).
108
+ // setupVisibilityObserver will trigger initMoon on first activation.
109
+ setupVisibilityObserver();
110
+
111
+ // Set up voice button events
112
+ setupVoiceEvents();
113
+
114
+ // Keyboard shortcuts
115
+ setupKeyboardShortcuts();
116
+
117
+ logger.debug('Voice: Initialized');
118
+ }
119
+
120
+ // ========== 3D Moon ==========
121
+
122
+ function initMoon() {
123
+ if (!moonCanvas) {
124
+ logger.warn('Voice: moonCanvas not found');
125
+ return;
126
+ }
127
+
128
+ if (typeof THREE === 'undefined') {
129
+ logger.warn('Voice: Three.js not loaded, using fallback');
130
+ showMoonFallback();
131
+ return;
132
+ }
133
+
134
+ // Set frame interval based on device (mobile = 30fps, desktop = 60fps)
135
+ frameInterval = window.innerWidth < 768 ? 1000 / 30 : 1000 / 60;
136
+
137
+ try {
138
+ const size = MOON_CANVAS_SIZE;
139
+ moonCanvas.width = size * 2;
140
+ moonCanvas.height = size * 2;
141
+
142
+ moonScene = new THREE.Scene();
143
+ moonCamera = new THREE.PerspectiveCamera(MOON_CAMERA_FOV, 1, MOON_CAMERA_NEAR, MOON_CAMERA_FAR);
144
+ moonCamera.position.z = MOON_CAMERA_Z;
145
+
146
+ moonRenderer = new THREE.WebGLRenderer({
147
+ canvas: moonCanvas,
148
+ alpha: true,
149
+ antialias: true
150
+ });
151
+ moonRenderer.setSize(size * 2, size * 2);
152
+ moonRenderer.setPixelRatio(window.devicePixelRatio);
153
+
154
+ // Lighting
155
+ const ambientLight = new THREE.AmbientLight(0x333333);
156
+ moonScene.add(ambientLight);
157
+
158
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.5);
159
+ keyLight.position.set(3, 1, 2);
160
+ moonScene.add(keyLight);
161
+
162
+ const rimLight = new THREE.DirectionalLight(0x4444ff, 0.3);
163
+ rimLight.position.set(-2, 0, -1);
164
+ moonScene.add(rimLight);
165
+
166
+ // Halo
167
+ const haloGeometry = new THREE.RingGeometry(HALO_INNER_RADIUS, HALO_OUTER_RADIUS, MOON_SPHERE_SEGMENTS);
168
+ const haloMaterial = new THREE.MeshBasicMaterial({
169
+ color: 0xffd700,
170
+ transparent: true,
171
+ opacity: 0,
172
+ side: THREE.DoubleSide
173
+ });
174
+ haloMesh = new THREE.Mesh(haloGeometry, haloMaterial);
175
+ haloMesh.position.z = 0.05;
176
+ moonScene.add(haloMesh);
177
+
178
+ // Moon sphere with texture
179
+ const geometry = new THREE.SphereGeometry(1, MOON_SPHERE_SEGMENTS, MOON_SPHERE_SEGMENTS);
180
+ const textureLoader = new THREE.TextureLoader();
181
+ const moonTexture = textureLoader.load('/moon_texture.jpg');
182
+ const material = new THREE.MeshStandardMaterial({
183
+ map: moonTexture,
184
+ roughness: 0.8,
185
+ metalness: 0.0
186
+ });
187
+
188
+ moonModel = new THREE.Mesh(geometry, material);
189
+ moonModel.rotation.x = MOON_INITIAL_TILT;
190
+ moonScene.add(moonModel);
191
+
192
+ animateMoon();
193
+ logger.debug('Voice: 3D moon initialized successfully');
194
+ } catch (err) {
195
+ logger.error('Voice: Failed to initialize 3D moon:', err);
196
+ showMoonFallback();
197
+ }
198
+ }
199
+
200
+ function showMoonFallback() {
201
+ // Show a static moon image as fallback when Three.js fails
202
+ if (!moonCanvas) return;
203
+ const ctx = moonCanvas.getContext('2d');
204
+ if (!ctx) return;
205
+
206
+ const img = new Image();
207
+ img.onload = () => {
208
+ const size = MOON_CANVAS_SIZE * 2;
209
+ moonCanvas.width = size;
210
+ moonCanvas.height = size;
211
+ ctx.beginPath();
212
+ ctx.arc(size/2, size/2, size/2, 0, Math.PI * 2);
213
+ ctx.closePath();
214
+ ctx.clip();
215
+ ctx.drawImage(img, 0, 0, size, size);
216
+ };
217
+ img.onerror = () => {
218
+ // If image fails, draw a simple circle
219
+ const size = MOON_CANVAS_SIZE * 2;
220
+ moonCanvas.width = size;
221
+ moonCanvas.height = size;
222
+ ctx.beginPath();
223
+ ctx.arc(size/2, size/2, size/2 - 2, 0, Math.PI * 2);
224
+ ctx.fillStyle = '#888';
225
+ ctx.fill();
226
+ };
227
+ img.src = '/moon_texture.jpg';
228
+ }
229
+
230
+ let moonInitialized = false;
231
+
232
+ function setupVisibilityObserver() {
233
+ const voiceRow = document.getElementById('voiceInputRow');
234
+ if (!voiceRow) return;
235
+
236
+ // Watch for class changes on the voice row to detect when it becomes visible
237
+ moonObserver = new MutationObserver((mutations) => {
238
+ mutations.forEach((mutation) => {
239
+ if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
240
+ const isActive = voiceRow.classList.contains('active');
241
+ if (isActive) {
242
+ // Initialize moon on first activation (deferred from init to avoid
243
+ // creating a WebGL context on a hidden/zero-sized canvas)
244
+ if (!moonInitialized) {
245
+ moonInitialized = true;
246
+ initMoon();
247
+ }
248
+ if (!moonAnimationRunning && moonRenderer) {
249
+ startMoonAnimation();
250
+ }
251
+ }
252
+ }
253
+ });
254
+ });
255
+
256
+ moonObserver.observe(voiceRow, {
257
+ attributes: true,
258
+ attributeFilter: ['class']
259
+ });
260
+
261
+ // Also listen for document visibility changes
262
+ document.addEventListener('visibilitychange', () => {
263
+ if (!document.hidden && voiceRow.classList.contains('active') && !moonAnimationRunning) {
264
+ if (!moonInitialized) {
265
+ moonInitialized = true;
266
+ initMoon();
267
+ }
268
+ if (moonRenderer) {
269
+ startMoonAnimation();
270
+ }
271
+ }
272
+ });
273
+ }
274
+
275
+ function animateMoon(timestamp) {
276
+ // Stop animation if document is hidden (tab not active)
277
+ if (document.hidden) {
278
+ moonAnimationRunning = false;
279
+ return;
280
+ }
281
+
282
+ // Check if voice mode is visible before animating
283
+ const voiceRow = document.getElementById('voiceInputRow');
284
+ const isVisible = voiceRow && (voiceRow.classList.contains('active') || voiceRow.offsetParent !== null);
285
+
286
+ if (!isVisible) {
287
+ moonAnimationRunning = false;
288
+ return;
289
+ }
290
+
291
+ // FPS throttling for mobile - skip frame if not enough time elapsed
292
+ if (timestamp) {
293
+ const elapsed = timestamp - lastFrameTime;
294
+ if (elapsed < frameInterval) {
295
+ // Skip this frame, but still schedule next check
296
+ if (moonAnimationRunning) {
297
+ requestAnimationFrame(animateMoon);
298
+ }
299
+ return;
300
+ }
301
+ lastFrameTime = timestamp;
302
+ }
303
+
304
+ // Update moon rotation
305
+ const realtimeActive = window.UplinkRealtime?.isActive?.() || false;
306
+ const realtimeMuted = window.UplinkRealtime?.isMuted?.() || false;
307
+
308
+ if (moonModel) {
309
+ // Fast spin when listening, slow when mic is muted (agent responding)
310
+ const speed = realtimeActive
311
+ ? (realtimeMuted ? BASE_ROTATION_SPEED : REALTIME_ROTATION_SPEED)
312
+ : (isRecording ? RECORDING_ROTATION_SPEED : BASE_ROTATION_SPEED);
313
+ moonModel.rotation.y += speed;
314
+ }
315
+
316
+ // Update halo
317
+ if (haloMesh) {
318
+ const active = isRecording || realtimeActive;
319
+ const targetOpacity = realtimeActive ? HALO_REALTIME_OPACITY : (isRecording ? HALO_RECORDING_OPACITY : 0);
320
+ haloOpacity += (targetOpacity - haloOpacity) * HALO_OPACITY_LERP;
321
+ haloMesh.material.opacity = haloOpacity;
322
+
323
+ if (active) {
324
+ haloMesh.rotation.z += HALO_ROTATION_SPEED;
325
+ const pulse = HALO_PULSE_BASE + Math.sin(Date.now() * HALO_PULSE_SPEED) * HALO_PULSE_AMPLITUDE;
326
+ haloMesh.material.opacity = haloOpacity * pulse;
327
+ const scalePulse = 1 + Math.sin(Date.now() * HALO_SCALE_SPEED) * HALO_SCALE_AMPLITUDE;
328
+ haloMesh.scale.set(scalePulse, scalePulse, 1);
329
+ }
330
+ }
331
+
332
+ // Render the scene
333
+ if (moonRenderer && moonScene && moonCamera) {
334
+ moonRenderer.render(moonScene, moonCamera);
335
+ }
336
+
337
+ // Continue animation loop
338
+ if (moonAnimationRunning) {
339
+ requestAnimationFrame(animateMoon);
340
+ }
341
+ }
342
+
343
+ // ========== Voice Recording ==========
344
+
345
+ async function initVoiceStream() {
346
+ if (stream) return true;
347
+
348
+ try {
349
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
350
+
351
+ // Audio level monitoring
352
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
353
+ analyser = audioContext.createAnalyser();
354
+ analyser.fftSize = 256;
355
+ const source = audioContext.createMediaStreamSource(stream);
356
+ source.connect(analyser);
357
+ monitorInterval = setInterval(monitorAudioLevel, AUDIO_MONITOR_INTERVAL_MS);
358
+
359
+ if (voiceStatus) voiceStatus.textContent = getVoiceStatusLabel();
360
+ return true;
361
+ } catch (e) {
362
+ if (voiceStatus) voiceStatus.textContent = 'Mic access denied';
363
+ return false;
364
+ }
365
+ }
366
+
367
+ function getAudioLevel() {
368
+ if (!analyser) return 0;
369
+ const data = new Uint8Array(analyser.frequencyBinCount);
370
+ analyser.getByteFrequencyData(data);
371
+ return data.reduce((a, b) => a + b) / data.length;
372
+ }
373
+
374
+ function monitorAudioLevel() {
375
+ if (!autoHoldMode || !isRecording) return;
376
+
377
+ const level = getAudioLevel();
378
+ if (level > SILENCE_THRESHOLD) {
379
+ silenceStart = null;
380
+ } else {
381
+ if (!silenceStart) silenceStart = Date.now();
382
+ else if (Date.now() - silenceStart > AUTO_HOLD_SILENCE_DURATION_MS) {
383
+ if (recordingStart && Date.now() - recordingStart > MIN_RECORDING_DURATION_MS) {
384
+ autoHoldMode = false;
385
+ voiceBtn?.classList.remove('auto-hold');
386
+ stopRecording();
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ // Cached config for STT hint check
393
+ let cachedClientConfig = null;
394
+ let configCacheTime = 0;
395
+ const CONFIG_CACHE_TTL_MS = 30000; // 30s cache
396
+
397
+ async function getClientConfig() {
398
+ const now = Date.now();
399
+ if (cachedClientConfig && (now - configCacheTime) < CONFIG_CACHE_TTL_MS) {
400
+ return cachedClientConfig;
401
+ }
402
+ try {
403
+ const resp = await fetch('/api/config');
404
+ if (resp.ok) {
405
+ cachedClientConfig = await resp.json();
406
+ configCacheTime = now;
407
+ }
408
+ } catch (e) {
409
+ // Ignore fetch errors — don't block recording
410
+ }
411
+ return cachedClientConfig;
412
+ }
413
+
414
+ async function startRecording() {
415
+ // Premium check
416
+ if (window.UplinkPremium && !window.UplinkPremium.isActive()) {
417
+ window.UplinkPremium.showUpgradeModal('Voice chat');
418
+ return;
419
+ }
420
+ if (isTransitioning) return;
421
+ if (isRecording) return; // Mutex: prevent multiple simultaneous recordings
422
+ isTransitioning = true;
423
+ const core = window.UplinkCore;
424
+ try {
425
+ if (core && core.chatState !== 'idle') return;
426
+
427
+ // Check voice mode and route accordingly
428
+ const cfg = await getClientConfig();
429
+ const voiceMode = cfg?.voiceMode || 'push-to-talk';
430
+
431
+ if (voiceMode === 'live-voice') {
432
+ // Use OpenAI Realtime standalone mode
433
+ if (window.UplinkRealtime && typeof window.UplinkRealtime.start === 'function') {
434
+ isTransitioning = false;
435
+ if (voiceStatus) voiceStatus.textContent = 'Listening...';
436
+ window.UplinkRealtime.start('standalone');
437
+ return;
438
+ } else {
439
+ const chat = window.UplinkChat;
440
+ chat?.addMessage('Real-time voice module not loaded', 'system');
441
+ isTransitioning = false;
442
+ return;
443
+ }
444
+ } else if (voiceMode === 'agent-voice') {
445
+ // Use agent voice bridge mode
446
+ if (window.UplinkRealtime && typeof window.UplinkRealtime.start === 'function') {
447
+ isTransitioning = false;
448
+ const name = cfg?.assistantName || 'Agent';
449
+ if (voiceStatus) voiceStatus.textContent = `Listening — ${name}`;
450
+ window.UplinkRealtime.start('agent');
451
+ return;
452
+ } else {
453
+ const chat = window.UplinkChat;
454
+ chat?.addMessage('Real-time voice module not loaded', 'system');
455
+ isTransitioning = false;
456
+ return;
457
+ }
458
+ }
459
+
460
+ // voiceMode === 'push-to-talk' — continue with existing recording flow
461
+
462
+ // Show hint if STT is not configured (don't block recording)
463
+ if (cfg && cfg.sttProvider === 'none' && !cfg.hasOpenaiKey) {
464
+ const chat = window.UplinkChat;
465
+ chat?.addMessage('Set up speech-to-text in Settings → Voice & STT to use voice input', 'system');
466
+ }
467
+
468
+ // Set state early to prevent race conditions from double-taps
469
+ if (core) core.chatState = 'recording';
470
+
471
+ if (!stream && !(await initVoiceStream())) {
472
+ if (core) core.chatState = 'idle'; // Reset on failure
473
+ return;
474
+ }
475
+ isRecording = true;
476
+ if (voiceBtn) voiceBtn.setAttribute('aria-label', 'Stop recording');
477
+ recordingStart = Date.now();
478
+ chunks = [];
479
+
480
+ try {
481
+ recorder = new MediaRecorder(stream);
482
+ recorder.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
483
+ recorder.onstop = sendVoice;
484
+ recorder.onerror = (e) => {
485
+ logger.error('Voice: MediaRecorder error', e.error);
486
+ if (window.UplinkDeveloper) {
487
+ window.UplinkDeveloper.logError(e.error || new Error('Recording failed'), 'MediaRecorder');
488
+ }
489
+ stopRecording();
490
+ resetVoice();
491
+ };
492
+ recorder.start(MEDIA_RECORDER_TIMESLICE_MS);
493
+ } catch (err) {
494
+ logger.error('Voice: Failed to create MediaRecorder', err);
495
+ if (window.UplinkDeveloper) {
496
+ window.UplinkDeveloper.logError(err, 'MediaRecorder init');
497
+ }
498
+ resetVoice();
499
+ return;
500
+ }
501
+
502
+ voiceBtn?.classList.add('recording');
503
+ if (voiceStatus) {
504
+ voiceStatus.textContent = 'Listening...';
505
+ voiceStatus.classList.add('recording');
506
+ }
507
+ if (voiceTimer) voiceTimer.classList.add('visible');
508
+
509
+ timerInterval = setInterval(() => {
510
+ const elapsed = Math.floor((Date.now() - recordingStart) / TIMER_UPDATE_INTERVAL_MS);
511
+ if (voiceTimer) {
512
+ voiceTimer.textContent = `${Math.floor(elapsed / 60)}:${(elapsed % 60).toString().padStart(2, '0')}`;
513
+ }
514
+ }, TIMER_UPDATE_INTERVAL_MS);
515
+
516
+ // M-17: Auto-stop recording after MAX_RECORDING_DURATION_MS
517
+ maxDurationTimeout = setTimeout(() => {
518
+ logger.warn(`Voice: Max recording duration (${MAX_RECORDING_DURATION_MS}ms) reached, stopping`);
519
+ if (voiceStatus) {
520
+ voiceStatus.textContent = 'Max duration reached';
521
+ }
522
+ stopRecording();
523
+ }, MAX_RECORDING_DURATION_MS);
524
+ } finally {
525
+ isTransitioning = false;
526
+ }
527
+ }
528
+
529
+ function stopRecording() {
530
+ if (isTransitioning) return;
531
+ isTransitioning = true;
532
+ try {
533
+ // Check if real-time voice is active
534
+ if (window.UplinkRealtime && typeof window.UplinkRealtime.isActive === 'function' && window.UplinkRealtime.isActive()) {
535
+ if (typeof window.UplinkRealtime.stop === 'function') {
536
+ window.UplinkRealtime.stop();
537
+ }
538
+ if (voiceStatus) voiceStatus.textContent = getVoiceStatusLabel();
539
+ isTransitioning = false;
540
+ return;
541
+ }
542
+
543
+ if (!isRecording) return;
544
+
545
+ clearInterval(timerInterval);
546
+ if (maxDurationTimeout) clearTimeout(maxDurationTimeout); // M-17: clear max duration timeout
547
+ if (voiceTimer) voiceTimer.classList.remove('visible');
548
+
549
+ if (recorder?.state === 'recording') recorder.stop();
550
+
551
+ voiceBtn?.classList.remove('recording');
552
+ if (voiceStatus) voiceStatus.classList.remove('recording');
553
+ isRecording = false;
554
+ if (voiceBtn) voiceBtn.setAttribute('aria-label', 'Start recording');
555
+ } finally {
556
+ isTransitioning = false;
557
+ }
558
+ }
559
+
560
+ async function sendVoice() {
561
+ if (!chunks.length) {
562
+ resetVoice();
563
+ return;
564
+ }
565
+
566
+ const core = window.UplinkCore;
567
+ if (core) core.chatState = 'processing';
568
+
569
+ voiceBtn?.classList.add('processing');
570
+ if (voiceStatus) voiceStatus.textContent = 'Sending audio...';
571
+
572
+ const chat = window.UplinkChat;
573
+ chat?.showTyping();
574
+
575
+ const blob = new Blob(chunks, { type: 'audio/webm' });
576
+ const form = new FormData();
577
+ form.append('audio', blob, 'rec.webm');
578
+
579
+ try {
580
+ const res = await fetch('/api/voice', { method: 'POST', body: form });
581
+
582
+ if (!res.ok) {
583
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
584
+ }
585
+
586
+ const data = await res.json();
587
+ chat?.hideTyping();
588
+
589
+ if (data.transcription) {
590
+ chat?.addMessage(data.transcription, 'user');
591
+ chat?.addMessage(data.response, 'assistant');
592
+
593
+ if (core?.audioResponses) {
594
+ // Queue all audio chunks (chunked TTS) or fall back to single URL
595
+ const urls = data.audioUrls?.length ? data.audioUrls : (data.audioUrl ? [data.audioUrl] : []);
596
+ if (urls.length && window.UplinkChat?.playAudio) {
597
+ urls.forEach(url => window.UplinkChat.playAudio(url));
598
+ } else if (urls.length) {
599
+ // Fallback to direct play (first chunk only)
600
+ const audio = document.getElementById('audio');
601
+ if (audio) {
602
+ audio.src = urls[0];
603
+ audio.play().catch(playErr => {
604
+ logger.warn('Voice: Audio playback failed', playErr);
605
+ });
606
+ }
607
+ }
608
+ }
609
+ } else if (data.error) {
610
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(data.error) || data.error;
611
+ chat?.addMessage(friendlyMsg, 'system');
612
+ if (window.UplinkDeveloper) {
613
+ window.UplinkDeveloper.logError(new Error(data.error), '/api/voice');
614
+ }
615
+ }
616
+ } catch (err) {
617
+ chat?.hideTyping();
618
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(err) || 'Voice processing failed';
619
+ chat?.addMessage(friendlyMsg, 'system');
620
+ if (window.UplinkDeveloper) {
621
+ window.UplinkDeveloper.logError(err, '/api/voice');
622
+ }
623
+ }
624
+
625
+ resetVoice();
626
+ }
627
+
628
+ function resetVoice() {
629
+ const core = window.UplinkCore;
630
+ if (core) core.chatState = 'idle';
631
+
632
+ autoHoldMode = false;
633
+ isRecording = false;
634
+ voiceBtn?.classList.remove('recording', 'processing', 'auto-hold');
635
+ if (voiceStatus) voiceStatus.textContent = getVoiceStatusLabel();
636
+ }
637
+
638
+ /**
639
+ * Get the appropriate voice status label based on voice mode config
640
+ * @returns {string}
641
+ */
642
+ function getVoiceStatusLabel() {
643
+ const cfg = cachedClientConfig;
644
+ const voiceMode = cfg?.voiceMode || 'push-to-talk';
645
+
646
+ if (voiceMode === 'agent-voice') {
647
+ const name = cfg?.assistantName || 'Agent';
648
+ return `Talk to ${name}`;
649
+ } else if (voiceMode === 'live-voice') {
650
+ return 'Tap to start live voice';
651
+ }
652
+ return 'Hold to talk';
653
+ }
654
+
655
+ function release() {
656
+ // Clear intervals
657
+ if (timerInterval) {
658
+ clearInterval(timerInterval);
659
+ timerInterval = null;
660
+ }
661
+ if (monitorInterval) {
662
+ clearInterval(monitorInterval);
663
+ monitorInterval = null;
664
+ }
665
+
666
+ // Stop recording if active
667
+ if (isRecording) {
668
+ stopRecording();
669
+ }
670
+
671
+ // Close audio context
672
+ if (audioContext) {
673
+ audioContext.close().catch(err => console.error('Voice: AudioContext close failed:', err));
674
+ audioContext = null;
675
+ analyser = null;
676
+ }
677
+
678
+ // Stop and release media stream
679
+ if (stream) {
680
+ stream.getTracks().forEach(track => track.stop());
681
+ stream = null;
682
+ }
683
+
684
+ // Clean up observer
685
+ if (moonObserver) {
686
+ moonObserver.disconnect();
687
+ moonObserver = null;
688
+ }
689
+
690
+ // Clean up Three.js resources
691
+ if (moonRenderer) {
692
+ moonRenderer.dispose();
693
+ moonRenderer = null;
694
+ }
695
+ if (moonModel?.geometry) {
696
+ moonModel.geometry.dispose();
697
+ }
698
+ if (moonModel?.material) {
699
+ moonModel.material.dispose();
700
+ if (moonModel.material.map) moonModel.material.map.dispose();
701
+ }
702
+ if (haloMesh?.geometry) {
703
+ haloMesh.geometry.dispose();
704
+ }
705
+ if (haloMesh?.material) {
706
+ haloMesh.material.dispose();
707
+ }
708
+ moonScene = null;
709
+ moonCamera = null;
710
+ moonModel = null;
711
+ haloMesh = null;
712
+ moonInitialized = false;
713
+
714
+ resetVoice();
715
+ logger.debug('Voice: Released');
716
+ }
717
+
718
+ // ========== Event Handlers ==========
719
+
720
+ function setupVoiceEvents() {
721
+ if (!voiceBtn) return;
722
+
723
+ // Double-tap detection
724
+ function handleTap() {
725
+ const now = Date.now();
726
+ const core = window.UplinkCore;
727
+
728
+ // If realtime voice is active, any tap stops it
729
+ if (window.UplinkRealtime && typeof window.UplinkRealtime.isActive === 'function' && window.UplinkRealtime.isActive()) {
730
+ window.UplinkRealtime.stop();
731
+ if (voiceStatus) voiceStatus.textContent = getVoiceStatusLabel();
732
+ lastTapTime = now;
733
+ return;
734
+ }
735
+
736
+ if (now - lastTapTime < DOUBLE_TAP_DELAY_MS && !autoHoldMode && core?.chatState === 'idle') {
737
+ autoHoldMode = true;
738
+ voiceBtn.classList.add('auto-hold');
739
+ silenceStart = null;
740
+ startRecording();
741
+ } else if (autoHoldMode && isRecording) {
742
+ autoHoldMode = false;
743
+ voiceBtn.classList.remove('auto-hold');
744
+ stopRecording();
745
+ }
746
+ lastTapTime = now;
747
+ }
748
+
749
+ // Track recent touch to prevent mouse event double-firing on touch devices
750
+ let lastTouchEnd = 0;
751
+ const TOUCH_MOUSE_DEBOUNCE_MS = 500;
752
+
753
+ // Touch events
754
+ let touchStart = 0, isTouchHold = false;
755
+
756
+ voiceBtn.addEventListener('touchstart', (e) => {
757
+ e.preventDefault();
758
+ touchStart = Date.now();
759
+ isTouchHold = false;
760
+
761
+ // Don't start new recording if realtime is active (tap will stop it)
762
+ if (window.UplinkRealtime?.isActive?.()) return;
763
+ if (autoHoldMode && isRecording) return;
764
+
765
+ const core = window.UplinkCore;
766
+ if (!autoHoldMode && core?.chatState === 'idle') {
767
+ setTimeout(() => {
768
+ if (!autoHoldMode && core?.chatState === 'idle' && Date.now() - touchStart > TAP_VS_HOLD_THRESHOLD_MS) {
769
+ isTouchHold = true;
770
+ startRecording();
771
+ }
772
+ }, HOLD_DETECTION_DELAY_MS);
773
+ }
774
+ }, { passive: false });
775
+
776
+ voiceBtn.addEventListener('touchend', (e) => {
777
+ e.preventDefault();
778
+ lastTouchEnd = Date.now();
779
+ if (Date.now() - touchStart < TAP_VS_HOLD_THRESHOLD_MS) handleTap();
780
+ else if (isTouchHold && !autoHoldMode && isRecording) stopRecording();
781
+ isTouchHold = false;
782
+ }, { passive: false });
783
+
784
+ // Mouse events - skip if recently handled by touch (prevents double-firing)
785
+ let mouseStart = 0, isMouseHold = false;
786
+
787
+ voiceBtn.addEventListener('mousedown', (e) => {
788
+ // Skip if this is a touch-originated mouse event
789
+ if (Date.now() - lastTouchEnd < TOUCH_MOUSE_DEBOUNCE_MS) return;
790
+
791
+ mouseStart = Date.now();
792
+ isMouseHold = false;
793
+
794
+ if (autoHoldMode && isRecording) return;
795
+
796
+ const core = window.UplinkCore;
797
+ if (!autoHoldMode && core?.chatState === 'idle') {
798
+ setTimeout(() => {
799
+ if (!autoHoldMode && core?.chatState === 'idle' && Date.now() - mouseStart > TAP_VS_HOLD_THRESHOLD_MS) {
800
+ isMouseHold = true;
801
+ startRecording();
802
+ }
803
+ }, HOLD_DETECTION_DELAY_MS);
804
+ }
805
+ }, { passive: true });
806
+
807
+ voiceBtn.addEventListener('mouseup', (e) => {
808
+ // Skip if this is a touch-originated mouse event
809
+ if (Date.now() - lastTouchEnd < TOUCH_MOUSE_DEBOUNCE_MS) return;
810
+
811
+ if (Date.now() - mouseStart < TAP_VS_HOLD_THRESHOLD_MS) handleTap();
812
+ else if (isMouseHold && !autoHoldMode && isRecording) stopRecording();
813
+ isMouseHold = false;
814
+ }, { passive: true });
815
+
816
+ voiceBtn.addEventListener('mouseleave', () => {
817
+ if (!autoHoldMode && isRecording && isMouseHold) {
818
+ stopRecording();
819
+ isMouseHold = false;
820
+ }
821
+ }, { passive: true });
822
+ }
823
+
824
+ function setupKeyboardShortcuts() {
825
+ document.addEventListener('keydown', (e) => {
826
+ const core = window.UplinkCore;
827
+ if (e.code === 'Space' &&
828
+ core?.mode === 'voice' &&
829
+ !e.target.matches('input, textarea') &&
830
+ core?.chatState === 'idle') {
831
+ e.preventDefault();
832
+ startRecording();
833
+ }
834
+ });
835
+
836
+ document.addEventListener('keyup', (e) => {
837
+ if (e.code === 'Space' && window.UplinkCore?.mode === 'voice' && isRecording) {
838
+ e.preventDefault();
839
+ stopRecording();
840
+ }
841
+ });
842
+ }
843
+
844
+ // Start moon animation (call when voice mode becomes visible)
845
+ function startMoonAnimation() {
846
+ // Deferred init: create the WebGL context now that the canvas is visible
847
+ if (!moonInitialized) {
848
+ moonInitialized = true;
849
+ initMoon();
850
+ }
851
+ if (!moonAnimationRunning && moonRenderer) {
852
+ moonAnimationRunning = true;
853
+ animateMoon();
854
+ }
855
+ }
856
+
857
+ // Expose API
858
+ export const UplinkVoice = {
859
+ startRecording,
860
+ stopRecording,
861
+ isRecording: () => isRecording,
862
+ release,
863
+ startMoonAnimation
864
+ };
865
+
866
+ import { UplinkCore } from './core.js';
867
+
868
+ // Backward compat: assign to window
869
+ window.UplinkVoice = UplinkVoice;
870
+
871
+ // Helper for init retry with exponential backoff
872
+ function scheduleInitRetry() {
873
+ if (initRetryCount >= MAX_INIT_RETRIES) {
874
+ logger.warn('Voice: Max init retries reached, giving up');
875
+ return;
876
+ }
877
+ initRetryCount++;
878
+ const delay = Math.min(INIT_RETRY_DELAY_MS * Math.pow(2, initRetryCount), 5000);
879
+ setTimeout(init, delay);
880
+ }
881
+
882
+ // Register and init
883
+ UplinkCore.registerModule('voice', init);