mcp-voice-hooks 1.0.8 → 1.0.13
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.
- package/README.md +56 -81
- package/bin/cli.js +10 -100
- package/dist/hook-merger.d.ts +1 -1
- package/dist/hook-merger.js +1 -1
- package/dist/hook-merger.js.map +1 -1
- package/dist/unified-server.js +147 -95
- package/dist/unified-server.js.map +1 -1
- package/package.json +1 -1
- package/public/app.js +451 -45
- package/public/index.html +255 -61
- package/.claude/hooks/pre-speak-hook.sh +0 -3
- package/.claude/hooks/pre-tool-hook.sh +0 -3
- package/.claude/hooks/pre-wait-hook.sh +0 -3
- package/.claude/hooks/stop-hook.sh +0 -3
- package/CLAUDE.local.md +0 -25
- package/test-npx-clean/mcp-voice-hooks-1.0.1.tgz +0 -0
- package/test-npx-clean/package/dist/chunk-IYGM5COW.js +0 -12
- package/test-npx-clean/package/dist/chunk-IYGM5COW.js.map +0 -1
- package/test-npx-clean/package/dist/index.d.ts +0 -2
- package/test-npx-clean/package/dist/index.js +0 -125
- package/test-npx-clean/package/dist/index.js.map +0 -1
- package/test-npx-clean/package/dist/unified-server.d.ts +0 -1
- package/test-npx-clean/package/dist/unified-server.js +0 -352
- package/test-npx-clean/package/dist/unified-server.js.map +0 -1
- package/test-npx-clean/package/mcp-voice-hooks-1.0.0.tgz +0 -0
- package/test-npx-clean/package/mcp-voice-hooks-1.0.1.tgz +0 -0
package/public/app.js
CHANGED
@@ -7,51 +7,72 @@ class VoiceHooksClient {
|
|
7
7
|
this.refreshBtn = document.getElementById('refreshBtn');
|
8
8
|
this.clearAllBtn = document.getElementById('clearAllBtn');
|
9
9
|
this.utterancesList = document.getElementById('utterancesList');
|
10
|
+
this.infoMessage = document.getElementById('infoMessage');
|
10
11
|
this.totalCount = document.getElementById('totalCount');
|
11
12
|
this.pendingCount = document.getElementById('pendingCount');
|
12
13
|
this.deliveredCount = document.getElementById('deliveredCount');
|
13
|
-
|
14
|
+
|
14
15
|
// Voice controls
|
15
16
|
this.listenBtn = document.getElementById('listenBtn');
|
16
17
|
this.listenBtnText = document.getElementById('listenBtnText');
|
17
18
|
this.listeningIndicator = document.getElementById('listeningIndicator');
|
18
19
|
this.interimText = document.getElementById('interimText');
|
19
|
-
|
20
|
+
|
20
21
|
// Speech recognition
|
21
22
|
this.recognition = null;
|
22
23
|
this.isListening = false;
|
23
24
|
this.initializeSpeechRecognition();
|
24
|
-
|
25
|
+
|
26
|
+
// Speech synthesis
|
27
|
+
this.initializeSpeechSynthesis();
|
28
|
+
|
29
|
+
// Server-Sent Events for TTS
|
30
|
+
this.initializeTTSEvents();
|
31
|
+
|
32
|
+
// TTS controls
|
33
|
+
this.voiceSelect = document.getElementById('voiceSelect');
|
34
|
+
this.speechRateSlider = document.getElementById('speechRate');
|
35
|
+
this.speechRateInput = document.getElementById('speechRateInput');
|
36
|
+
this.testTTSBtn = document.getElementById('testTTSBtn');
|
37
|
+
this.voiceResponsesToggle = document.getElementById('voiceResponsesToggle');
|
38
|
+
this.voiceOptions = document.getElementById('voiceOptions');
|
39
|
+
this.localVoicesGroup = document.getElementById('localVoicesGroup');
|
40
|
+
this.cloudVoicesGroup = document.getElementById('cloudVoicesGroup');
|
41
|
+
this.rateWarning = document.getElementById('rateWarning');
|
42
|
+
this.systemVoiceInfo = document.getElementById('systemVoiceInfo');
|
43
|
+
|
44
|
+
// Load saved preferences
|
45
|
+
this.loadPreferences();
|
46
|
+
|
25
47
|
this.setupEventListeners();
|
26
48
|
this.loadData();
|
27
|
-
|
49
|
+
|
28
50
|
// Auto-refresh every 2 seconds
|
29
51
|
setInterval(() => this.loadData(), 2000);
|
30
52
|
}
|
31
|
-
|
53
|
+
|
32
54
|
initializeSpeechRecognition() {
|
33
55
|
// Check for browser support
|
34
56
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
35
|
-
|
57
|
+
|
36
58
|
if (!SpeechRecognition) {
|
37
59
|
console.error('Speech recognition not supported in this browser');
|
38
60
|
this.listenBtn.disabled = true;
|
39
61
|
this.listenBtnText.textContent = 'Not Supported';
|
40
62
|
return;
|
41
63
|
}
|
42
|
-
|
64
|
+
|
43
65
|
this.recognition = new SpeechRecognition();
|
44
66
|
this.recognition.continuous = true;
|
45
67
|
this.recognition.interimResults = true;
|
46
|
-
|
47
|
-
|
68
|
+
|
48
69
|
// Handle results
|
49
70
|
this.recognition.onresult = (event) => {
|
50
71
|
let interimTranscript = '';
|
51
|
-
|
72
|
+
|
52
73
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
53
74
|
const transcript = event.results[i][0].transcript;
|
54
|
-
|
75
|
+
|
55
76
|
if (event.results[i].isFinal) {
|
56
77
|
// User paused - send as complete utterance
|
57
78
|
this.sendVoiceUtterance(transcript);
|
@@ -63,31 +84,31 @@ class VoiceHooksClient {
|
|
63
84
|
interimTranscript += transcript;
|
64
85
|
}
|
65
86
|
}
|
66
|
-
|
87
|
+
|
67
88
|
if (interimTranscript) {
|
68
89
|
this.interimText.textContent = interimTranscript;
|
69
90
|
this.interimText.classList.add('active');
|
70
91
|
}
|
71
92
|
};
|
72
|
-
|
93
|
+
|
73
94
|
// Handle errors
|
74
95
|
this.recognition.onerror = (event) => {
|
75
96
|
console.error('Speech recognition error:', event.error);
|
76
|
-
|
97
|
+
|
77
98
|
if (event.error === 'no-speech') {
|
78
99
|
// Continue listening
|
79
100
|
return;
|
80
101
|
}
|
81
|
-
|
102
|
+
|
82
103
|
if (event.error === 'not-allowed') {
|
83
104
|
alert('Microphone access denied. Please allow microphone access to use voice input.');
|
84
105
|
} else {
|
85
106
|
alert(`Speech recognition error: ${event.error}`);
|
86
107
|
}
|
87
|
-
|
108
|
+
|
88
109
|
this.stopListening();
|
89
110
|
};
|
90
|
-
|
111
|
+
|
91
112
|
// Handle end
|
92
113
|
this.recognition.onend = () => {
|
93
114
|
if (this.isListening) {
|
@@ -101,27 +122,67 @@ class VoiceHooksClient {
|
|
101
122
|
}
|
102
123
|
};
|
103
124
|
}
|
104
|
-
|
125
|
+
|
105
126
|
setupEventListeners() {
|
106
127
|
this.sendBtn.addEventListener('click', () => this.sendUtterance());
|
107
128
|
this.refreshBtn.addEventListener('click', () => this.loadData());
|
108
129
|
this.clearAllBtn.addEventListener('click', () => this.clearAllUtterances());
|
109
130
|
this.listenBtn.addEventListener('click', () => this.toggleListening());
|
110
|
-
|
131
|
+
|
111
132
|
this.utteranceInput.addEventListener('keypress', (e) => {
|
112
133
|
if (e.key === 'Enter') {
|
113
134
|
this.sendUtterance();
|
114
135
|
}
|
115
136
|
});
|
137
|
+
|
138
|
+
// TTS controls
|
139
|
+
this.voiceSelect.addEventListener('change', (e) => {
|
140
|
+
this.selectedVoice = e.target.value;
|
141
|
+
// Save selected voice to localStorage
|
142
|
+
localStorage.setItem('selectedVoice', this.selectedVoice);
|
143
|
+
this.updateVoicePreferences();
|
144
|
+
this.updateVoiceWarnings();
|
145
|
+
});
|
146
|
+
|
147
|
+
this.speechRateSlider.addEventListener('input', (e) => {
|
148
|
+
this.speechRate = parseFloat(e.target.value);
|
149
|
+
this.speechRateInput.value = this.speechRate.toFixed(1);
|
150
|
+
// Save rate to localStorage
|
151
|
+
localStorage.setItem('speechRate', this.speechRate.toString());
|
152
|
+
});
|
153
|
+
|
154
|
+
this.speechRateInput.addEventListener('input', (e) => {
|
155
|
+
let value = parseFloat(e.target.value);
|
156
|
+
if (!isNaN(value)) {
|
157
|
+
value = Math.max(0.5, Math.min(5, value)); // Clamp to valid range
|
158
|
+
this.speechRate = value;
|
159
|
+
this.speechRateSlider.value = value.toString();
|
160
|
+
this.speechRateInput.value = value.toFixed(1);
|
161
|
+
// Save rate to localStorage
|
162
|
+
localStorage.setItem('speechRate', this.speechRate.toString());
|
163
|
+
}
|
164
|
+
});
|
165
|
+
|
166
|
+
this.testTTSBtn.addEventListener('click', () => {
|
167
|
+
this.speakText('Hello! This is a test of the text-to-speech voice.');
|
168
|
+
});
|
169
|
+
|
170
|
+
// Voice toggle listeners
|
171
|
+
this.voiceResponsesToggle.addEventListener('change', (e) => {
|
172
|
+
const enabled = e.target.checked;
|
173
|
+
localStorage.setItem('voiceResponsesEnabled', enabled);
|
174
|
+
this.updateVoicePreferences();
|
175
|
+
this.updateVoiceOptionsVisibility();
|
176
|
+
});
|
116
177
|
}
|
117
|
-
|
178
|
+
|
118
179
|
async sendUtterance() {
|
119
180
|
const text = this.utteranceInput.value.trim();
|
120
181
|
if (!text) return;
|
121
|
-
|
182
|
+
|
122
183
|
this.sendBtn.disabled = true;
|
123
184
|
this.sendBtn.textContent = 'Sending...';
|
124
|
-
|
185
|
+
|
125
186
|
try {
|
126
187
|
const response = await fetch(`${this.baseUrl}/api/potential-utterances`, {
|
127
188
|
method: 'POST',
|
@@ -133,7 +194,7 @@ class VoiceHooksClient {
|
|
133
194
|
timestamp: new Date().toISOString()
|
134
195
|
}),
|
135
196
|
});
|
136
|
-
|
197
|
+
|
137
198
|
if (response.ok) {
|
138
199
|
this.utteranceInput.value = '';
|
139
200
|
this.loadData(); // Refresh the list
|
@@ -149,7 +210,7 @@ class VoiceHooksClient {
|
|
149
210
|
this.sendBtn.textContent = 'Send';
|
150
211
|
}
|
151
212
|
}
|
152
|
-
|
213
|
+
|
153
214
|
async loadData() {
|
154
215
|
try {
|
155
216
|
// Load status
|
@@ -158,7 +219,7 @@ class VoiceHooksClient {
|
|
158
219
|
const status = await statusResponse.json();
|
159
220
|
this.updateStatus(status);
|
160
221
|
}
|
161
|
-
|
222
|
+
|
162
223
|
// Load utterances
|
163
224
|
const utterancesResponse = await fetch(`${this.baseUrl}/api/utterances?limit=20`);
|
164
225
|
if (utterancesResponse.ok) {
|
@@ -169,19 +230,30 @@ class VoiceHooksClient {
|
|
169
230
|
console.error('Failed to load data:', error);
|
170
231
|
}
|
171
232
|
}
|
172
|
-
|
233
|
+
|
173
234
|
updateStatus(status) {
|
174
235
|
this.totalCount.textContent = status.total;
|
175
236
|
this.pendingCount.textContent = status.pending;
|
176
237
|
this.deliveredCount.textContent = status.delivered;
|
177
238
|
}
|
178
|
-
|
239
|
+
|
179
240
|
updateUtterancesList(utterances) {
|
180
241
|
if (utterances.length === 0) {
|
181
242
|
this.utterancesList.innerHTML = '<div class="empty-state">No utterances yet. Type something above to get started!</div>';
|
243
|
+
this.infoMessage.style.display = 'none';
|
182
244
|
return;
|
183
245
|
}
|
184
|
-
|
246
|
+
|
247
|
+
// Check if all messages are pending
|
248
|
+
const allPending = utterances.every(u => u.status === 'pending');
|
249
|
+
if (allPending) {
|
250
|
+
// Show info message but don't replace the utterances list
|
251
|
+
this.infoMessage.style.display = 'block';
|
252
|
+
} else {
|
253
|
+
// Hide info message when at least one utterance is delivered
|
254
|
+
this.infoMessage.style.display = 'none';
|
255
|
+
}
|
256
|
+
|
185
257
|
this.utterancesList.innerHTML = utterances.map(utterance => `
|
186
258
|
<div class="utterance-item">
|
187
259
|
<div class="utterance-text">${this.escapeHtml(utterance.text)}</div>
|
@@ -194,18 +266,18 @@ class VoiceHooksClient {
|
|
194
266
|
</div>
|
195
267
|
`).join('');
|
196
268
|
}
|
197
|
-
|
269
|
+
|
198
270
|
formatTimestamp(timestamp) {
|
199
271
|
const date = new Date(timestamp);
|
200
272
|
return date.toLocaleTimeString();
|
201
273
|
}
|
202
|
-
|
274
|
+
|
203
275
|
escapeHtml(text) {
|
204
276
|
const div = document.createElement('div');
|
205
277
|
div.textContent = text;
|
206
278
|
return div.innerHTML;
|
207
279
|
}
|
208
|
-
|
280
|
+
|
209
281
|
toggleListening() {
|
210
282
|
if (this.isListening) {
|
211
283
|
this.stopListening();
|
@@ -213,13 +285,13 @@ class VoiceHooksClient {
|
|
213
285
|
this.startListening();
|
214
286
|
}
|
215
287
|
}
|
216
|
-
|
217
|
-
startListening() {
|
288
|
+
|
289
|
+
async startListening() {
|
218
290
|
if (!this.recognition) {
|
219
291
|
alert('Speech recognition not supported in this browser');
|
220
292
|
return;
|
221
293
|
}
|
222
|
-
|
294
|
+
|
223
295
|
try {
|
224
296
|
this.recognition.start();
|
225
297
|
this.isListening = true;
|
@@ -227,13 +299,16 @@ class VoiceHooksClient {
|
|
227
299
|
this.listenBtnText.textContent = 'Stop Listening';
|
228
300
|
this.listeningIndicator.classList.add('active');
|
229
301
|
this.debugLog('Started listening');
|
302
|
+
|
303
|
+
// Notify server that voice input is active
|
304
|
+
await this.updateVoiceInputState(true);
|
230
305
|
} catch (e) {
|
231
306
|
console.error('Failed to start recognition:', e);
|
232
307
|
alert('Failed to start speech recognition. Please try again.');
|
233
308
|
}
|
234
309
|
}
|
235
|
-
|
236
|
-
stopListening() {
|
310
|
+
|
311
|
+
async stopListening() {
|
237
312
|
if (this.recognition) {
|
238
313
|
this.isListening = false;
|
239
314
|
this.recognition.stop();
|
@@ -243,15 +318,18 @@ class VoiceHooksClient {
|
|
243
318
|
this.interimText.textContent = '';
|
244
319
|
this.interimText.classList.remove('active');
|
245
320
|
this.debugLog('Stopped listening');
|
321
|
+
|
322
|
+
// Notify server that voice input is no longer active
|
323
|
+
await this.updateVoiceInputState(false);
|
246
324
|
}
|
247
325
|
}
|
248
|
-
|
326
|
+
|
249
327
|
async sendVoiceUtterance(text) {
|
250
328
|
const trimmedText = text.trim();
|
251
329
|
if (!trimmedText) return;
|
252
|
-
|
330
|
+
|
253
331
|
this.debugLog('Sending voice utterance:', trimmedText);
|
254
|
-
|
332
|
+
|
255
333
|
try {
|
256
334
|
const response = await fetch(`${this.baseUrl}/api/potential-utterances`, {
|
257
335
|
method: 'POST',
|
@@ -263,7 +341,7 @@ class VoiceHooksClient {
|
|
263
341
|
timestamp: new Date().toISOString()
|
264
342
|
}),
|
265
343
|
});
|
266
|
-
|
344
|
+
|
267
345
|
if (response.ok) {
|
268
346
|
this.loadData(); // Refresh the list
|
269
347
|
} else {
|
@@ -274,12 +352,12 @@ class VoiceHooksClient {
|
|
274
352
|
console.error('Failed to send voice utterance:', error);
|
275
353
|
}
|
276
354
|
}
|
277
|
-
|
355
|
+
|
278
356
|
async clearAllUtterances() {
|
279
|
-
|
357
|
+
|
280
358
|
this.clearAllBtn.disabled = true;
|
281
359
|
this.clearAllBtn.textContent = 'Clearing...';
|
282
|
-
|
360
|
+
|
283
361
|
try {
|
284
362
|
const response = await fetch(`${this.baseUrl}/api/utterances`, {
|
285
363
|
method: 'DELETE',
|
@@ -287,7 +365,7 @@ class VoiceHooksClient {
|
|
287
365
|
'Content-Type': 'application/json',
|
288
366
|
}
|
289
367
|
});
|
290
|
-
|
368
|
+
|
291
369
|
if (response.ok) {
|
292
370
|
const result = await response.json();
|
293
371
|
this.loadData(); // Refresh the list
|
@@ -304,12 +382,340 @@ class VoiceHooksClient {
|
|
304
382
|
this.clearAllBtn.textContent = 'Clear All';
|
305
383
|
}
|
306
384
|
}
|
307
|
-
|
385
|
+
|
308
386
|
debugLog(...args) {
|
309
387
|
if (this.debug) {
|
310
388
|
console.log(...args);
|
311
389
|
}
|
312
390
|
}
|
391
|
+
|
392
|
+
initializeSpeechSynthesis() {
|
393
|
+
// Check for browser support
|
394
|
+
if (!window.speechSynthesis) {
|
395
|
+
console.warn('Speech synthesis not supported in this browser');
|
396
|
+
return;
|
397
|
+
}
|
398
|
+
|
399
|
+
// Get available voices
|
400
|
+
this.voices = [];
|
401
|
+
const loadVoices = () => {
|
402
|
+
this.voices = window.speechSynthesis.getVoices();
|
403
|
+
this.debugLog('Available voices:', this.voices);
|
404
|
+
this.populateVoiceList();
|
405
|
+
};
|
406
|
+
|
407
|
+
// Load voices initially and on change
|
408
|
+
loadVoices();
|
409
|
+
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
410
|
+
window.speechSynthesis.onvoiceschanged = loadVoices;
|
411
|
+
}
|
412
|
+
|
413
|
+
// Set default voice preferences
|
414
|
+
this.speechRate = 1.0;
|
415
|
+
this.speechPitch = 1.0;
|
416
|
+
this.selectedVoice = 'system';
|
417
|
+
}
|
418
|
+
|
419
|
+
initializeTTSEvents() {
|
420
|
+
// Connect to Server-Sent Events endpoint
|
421
|
+
this.eventSource = new EventSource(`${this.baseUrl}/api/tts-events`);
|
422
|
+
|
423
|
+
this.eventSource.onmessage = (event) => {
|
424
|
+
try {
|
425
|
+
const data = JSON.parse(event.data);
|
426
|
+
this.debugLog('TTS Event:', data);
|
427
|
+
|
428
|
+
if (data.type === 'speak' && data.text) {
|
429
|
+
this.speakText(data.text);
|
430
|
+
}
|
431
|
+
} catch (error) {
|
432
|
+
console.error('Failed to parse TTS event:', error);
|
433
|
+
}
|
434
|
+
};
|
435
|
+
|
436
|
+
this.eventSource.onerror = (error) => {
|
437
|
+
console.error('SSE connection error:', error);
|
438
|
+
// Will automatically reconnect
|
439
|
+
};
|
440
|
+
|
441
|
+
this.eventSource.onopen = () => {
|
442
|
+
this.debugLog('TTS Events connected');
|
443
|
+
// Sync state when connection is established (includes reconnections)
|
444
|
+
this.syncStateWithServer();
|
445
|
+
};
|
446
|
+
}
|
447
|
+
|
448
|
+
populateVoiceList() {
|
449
|
+
if (!this.voiceSelect || !this.localVoicesGroup || !this.cloudVoicesGroup) return;
|
450
|
+
|
451
|
+
// Clear existing browser voice options
|
452
|
+
this.localVoicesGroup.innerHTML = '';
|
453
|
+
this.cloudVoicesGroup.innerHTML = '';
|
454
|
+
|
455
|
+
// List of voices to exclude (novelty, Eloquence, and non-premium voices)
|
456
|
+
const excludedVoices = [
|
457
|
+
// Eloquence voices
|
458
|
+
'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley',
|
459
|
+
// Novelty voices
|
460
|
+
'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos',
|
461
|
+
'Good News', 'Jester', 'Organ', 'Superstar', 'Trinoids', 'Whisper',
|
462
|
+
'Wobble', 'Zarvox',
|
463
|
+
// Voices without premium options
|
464
|
+
'Fred', 'Junior', 'Kathy', 'Ralph'
|
465
|
+
];
|
466
|
+
|
467
|
+
// Filter and add only English voices
|
468
|
+
this.voices.forEach((voice, index) => {
|
469
|
+
// Only include English voices (en-US, en-GB, en-AU, etc.)
|
470
|
+
if (voice.lang.toLowerCase().startsWith('en-')) {
|
471
|
+
// Check if voice should be excluded
|
472
|
+
const voiceName = voice.name;
|
473
|
+
const isExcluded = excludedVoices.some(excluded =>
|
474
|
+
voiceName.toLowerCase().startsWith(excluded.toLowerCase())
|
475
|
+
);
|
476
|
+
|
477
|
+
if (!isExcluded) {
|
478
|
+
const option = document.createElement('option');
|
479
|
+
option.value = `browser:${index}`;
|
480
|
+
// Show voice name and language code
|
481
|
+
option.textContent = `${voice.name} (${voice.lang})`;
|
482
|
+
|
483
|
+
// Categorize voices
|
484
|
+
if (voice.localService) {
|
485
|
+
this.localVoicesGroup.appendChild(option);
|
486
|
+
this.debugLog(voice.voiceURI);
|
487
|
+
} else {
|
488
|
+
this.cloudVoicesGroup.appendChild(option);
|
489
|
+
}
|
490
|
+
}
|
491
|
+
}
|
492
|
+
});
|
493
|
+
|
494
|
+
// Hide empty groups
|
495
|
+
if (this.localVoicesGroup.children.length === 0) {
|
496
|
+
this.localVoicesGroup.style.display = 'none';
|
497
|
+
} else {
|
498
|
+
this.localVoicesGroup.style.display = '';
|
499
|
+
}
|
500
|
+
|
501
|
+
if (this.cloudVoicesGroup.children.length === 0) {
|
502
|
+
this.cloudVoicesGroup.style.display = 'none';
|
503
|
+
} else {
|
504
|
+
this.cloudVoicesGroup.style.display = '';
|
505
|
+
}
|
506
|
+
|
507
|
+
// Restore saved selection
|
508
|
+
const savedVoice = localStorage.getItem('selectedVoice');
|
509
|
+
if (savedVoice) {
|
510
|
+
this.voiceSelect.value = savedVoice;
|
511
|
+
this.selectedVoice = savedVoice;
|
512
|
+
} else {
|
513
|
+
this.selectedVoice = 'system';
|
514
|
+
}
|
515
|
+
|
516
|
+
// Update warnings based on selected voice
|
517
|
+
this.updateVoiceWarnings();
|
518
|
+
}
|
519
|
+
|
520
|
+
async speakText(text) {
|
521
|
+
// Check if we should use system voice
|
522
|
+
if (this.selectedVoice === 'system') {
|
523
|
+
// Use Mac system voice via server
|
524
|
+
try {
|
525
|
+
const response = await fetch(`${this.baseUrl}/api/speak-system`, {
|
526
|
+
method: 'POST',
|
527
|
+
headers: {
|
528
|
+
'Content-Type': 'application/json',
|
529
|
+
},
|
530
|
+
body: JSON.stringify({
|
531
|
+
text: text,
|
532
|
+
rate: Math.round(this.speechRate * 150) // Convert rate to words per minute
|
533
|
+
}),
|
534
|
+
});
|
535
|
+
|
536
|
+
if (!response.ok) {
|
537
|
+
const error = await response.json();
|
538
|
+
console.error('Failed to speak via system voice:', error);
|
539
|
+
}
|
540
|
+
} catch (error) {
|
541
|
+
console.error('Failed to call speak-system API:', error);
|
542
|
+
}
|
543
|
+
} else {
|
544
|
+
// Use browser voice
|
545
|
+
if (!window.speechSynthesis) {
|
546
|
+
console.error('Speech synthesis not available');
|
547
|
+
return;
|
548
|
+
}
|
549
|
+
|
550
|
+
// Cancel any ongoing speech
|
551
|
+
window.speechSynthesis.cancel();
|
552
|
+
|
553
|
+
// Create utterance
|
554
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
555
|
+
|
556
|
+
// Set voice if using browser voice
|
557
|
+
if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) {
|
558
|
+
const voiceIndex = parseInt(this.selectedVoice.substring(8));
|
559
|
+
if (this.voices[voiceIndex]) {
|
560
|
+
utterance.voice = this.voices[voiceIndex];
|
561
|
+
}
|
562
|
+
}
|
563
|
+
|
564
|
+
// Set speech properties
|
565
|
+
utterance.rate = this.speechRate;
|
566
|
+
utterance.pitch = this.speechPitch;
|
567
|
+
|
568
|
+
// Event handlers
|
569
|
+
utterance.onstart = () => {
|
570
|
+
this.debugLog('Started speaking:', text);
|
571
|
+
};
|
572
|
+
|
573
|
+
utterance.onend = () => {
|
574
|
+
this.debugLog('Finished speaking');
|
575
|
+
};
|
576
|
+
|
577
|
+
utterance.onerror = (event) => {
|
578
|
+
console.error('Speech synthesis error:', event);
|
579
|
+
};
|
580
|
+
|
581
|
+
// Speak the text
|
582
|
+
window.speechSynthesis.speak(utterance);
|
583
|
+
}
|
584
|
+
}
|
585
|
+
|
586
|
+
loadPreferences() {
|
587
|
+
// Simple localStorage with defaults to true
|
588
|
+
const storedVoiceResponses = localStorage.getItem('voiceResponsesEnabled');
|
589
|
+
|
590
|
+
// Default to true if not stored
|
591
|
+
const voiceResponsesEnabled = storedVoiceResponses !== null
|
592
|
+
? storedVoiceResponses === 'true'
|
593
|
+
: true;
|
594
|
+
|
595
|
+
// Set the checkbox
|
596
|
+
this.voiceResponsesToggle.checked = voiceResponsesEnabled;
|
597
|
+
|
598
|
+
// Save to localStorage if this is first time
|
599
|
+
if (storedVoiceResponses === null) {
|
600
|
+
localStorage.setItem('voiceResponsesEnabled', 'true');
|
601
|
+
}
|
602
|
+
|
603
|
+
// Load voice settings
|
604
|
+
const storedRate = localStorage.getItem('speechRate');
|
605
|
+
if (storedRate !== null) {
|
606
|
+
this.speechRate = parseFloat(storedRate);
|
607
|
+
this.speechRateSlider.value = storedRate;
|
608
|
+
this.speechRateInput.value = this.speechRate.toFixed(1);
|
609
|
+
}
|
610
|
+
|
611
|
+
// Load selected voice (will be applied after voices load)
|
612
|
+
this.selectedVoice = localStorage.getItem('selectedVoice') || 'system';
|
613
|
+
|
614
|
+
// Update UI visibility
|
615
|
+
this.updateVoiceOptionsVisibility();
|
616
|
+
|
617
|
+
// Send preferences to server
|
618
|
+
this.updateVoicePreferences();
|
619
|
+
|
620
|
+
// Update warnings after preferences are loaded
|
621
|
+
this.updateVoiceWarnings();
|
622
|
+
}
|
623
|
+
|
624
|
+
updateVoiceOptionsVisibility() {
|
625
|
+
const voiceResponsesEnabled = this.voiceResponsesToggle.checked;
|
626
|
+
this.voiceOptions.style.display = voiceResponsesEnabled ? 'flex' : 'none';
|
627
|
+
}
|
628
|
+
|
629
|
+
async updateVoicePreferences() {
|
630
|
+
const voiceResponsesEnabled = this.voiceResponsesToggle.checked;
|
631
|
+
|
632
|
+
try {
|
633
|
+
// Send preferences to server
|
634
|
+
await fetch(`${this.baseUrl}/api/voice-preferences`, {
|
635
|
+
method: 'POST',
|
636
|
+
headers: {
|
637
|
+
'Content-Type': 'application/json',
|
638
|
+
},
|
639
|
+
body: JSON.stringify({
|
640
|
+
voiceResponsesEnabled
|
641
|
+
}),
|
642
|
+
});
|
643
|
+
|
644
|
+
this.debugLog('Voice preferences updated:', { voiceResponsesEnabled });
|
645
|
+
} catch (error) {
|
646
|
+
console.error('Failed to update voice preferences:', error);
|
647
|
+
}
|
648
|
+
}
|
649
|
+
|
650
|
+
async updateVoiceInputState(active) {
|
651
|
+
try {
|
652
|
+
// Send voice input state to server
|
653
|
+
await fetch(`${this.baseUrl}/api/voice-input-state`, {
|
654
|
+
method: 'POST',
|
655
|
+
headers: {
|
656
|
+
'Content-Type': 'application/json',
|
657
|
+
},
|
658
|
+
body: JSON.stringify({ active }),
|
659
|
+
});
|
660
|
+
|
661
|
+
this.debugLog('Voice input state updated:', { active });
|
662
|
+
} catch (error) {
|
663
|
+
console.error('Failed to update voice input state:', error);
|
664
|
+
}
|
665
|
+
}
|
666
|
+
|
667
|
+
async syncStateWithServer() {
|
668
|
+
this.debugLog('Syncing state with server after reconnection');
|
669
|
+
|
670
|
+
// Sync voice response preferences
|
671
|
+
await this.updateVoicePreferences();
|
672
|
+
|
673
|
+
// Sync voice input state if currently listening
|
674
|
+
if (this.isListening) {
|
675
|
+
await this.updateVoiceInputState(true);
|
676
|
+
}
|
677
|
+
}
|
678
|
+
|
679
|
+
updateVoiceWarnings() {
|
680
|
+
// Show/hide warnings based on selected voice
|
681
|
+
if (this.selectedVoice === 'system') {
|
682
|
+
// Show system voice info for Mac System Voice
|
683
|
+
this.systemVoiceInfo.style.display = 'flex';
|
684
|
+
this.rateWarning.style.display = 'none';
|
685
|
+
} else if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) {
|
686
|
+
// Check voice properties
|
687
|
+
const voiceIndex = parseInt(this.selectedVoice.substring(8));
|
688
|
+
const voice = this.voices[voiceIndex];
|
689
|
+
|
690
|
+
if (voice) {
|
691
|
+
const isGoogleVoice = voice.name.toLowerCase().includes('google');
|
692
|
+
const isLocalVoice = voice.localService === true;
|
693
|
+
|
694
|
+
// Show appropriate warnings
|
695
|
+
if (isGoogleVoice) {
|
696
|
+
// Show rate warning for Google voices
|
697
|
+
this.rateWarning.style.display = 'flex';
|
698
|
+
} else {
|
699
|
+
this.rateWarning.style.display = 'none';
|
700
|
+
}
|
701
|
+
|
702
|
+
if (isLocalVoice) {
|
703
|
+
// Show system info for local browser voices
|
704
|
+
this.systemVoiceInfo.style.display = 'flex';
|
705
|
+
} else {
|
706
|
+
this.systemVoiceInfo.style.display = 'none';
|
707
|
+
}
|
708
|
+
} else {
|
709
|
+
// Hide both warnings if voice not found
|
710
|
+
this.rateWarning.style.display = 'none';
|
711
|
+
this.systemVoiceInfo.style.display = 'none';
|
712
|
+
}
|
713
|
+
} else {
|
714
|
+
// Hide both warnings if no voice selected
|
715
|
+
this.rateWarning.style.display = 'none';
|
716
|
+
this.systemVoiceInfo.style.display = 'none';
|
717
|
+
}
|
718
|
+
}
|
313
719
|
}
|
314
720
|
|
315
721
|
// Initialize the client when the page loads
|