@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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);
|