talktocursor 1.0.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.
@@ -0,0 +1,1574 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cursor TTS Settings</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0f0f13;
12
+ --surface: #1a1a24;
13
+ --surface-hover: #22222e;
14
+ --border: #2a2a3a;
15
+ --border-focus: #6366f1;
16
+ --text: #e4e4ed;
17
+ --text-muted: #8888a0;
18
+ --primary: #6366f1;
19
+ --primary-hover: #818cf8;
20
+ --success: #22c55e;
21
+ --error: #ef4444;
22
+ --warning: #f59e0b;
23
+ --radius: 12px;
24
+ --radius-sm: 8px;
25
+ }
26
+
27
+ body {
28
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ min-height: 100vh;
32
+ line-height: 1.6;
33
+ }
34
+
35
+ .container {
36
+ max-width: 640px;
37
+ margin: 0 auto;
38
+ padding: 48px 24px;
39
+ }
40
+
41
+ header {
42
+ text-align: center;
43
+ margin-bottom: 40px;
44
+ }
45
+
46
+ header .icon {
47
+ font-size: 48px;
48
+ margin-bottom: 12px;
49
+ }
50
+
51
+ header h1 {
52
+ font-size: 28px;
53
+ font-weight: 700;
54
+ letter-spacing: -0.5px;
55
+ margin-bottom: 6px;
56
+ }
57
+
58
+ header p {
59
+ color: var(--text-muted);
60
+ font-size: 15px;
61
+ }
62
+
63
+ .card {
64
+ background: var(--surface);
65
+ border: 1px solid var(--border);
66
+ border-radius: var(--radius);
67
+ padding: 28px;
68
+ margin-bottom: 20px;
69
+ }
70
+
71
+ .card-title {
72
+ font-size: 16px;
73
+ font-weight: 600;
74
+ margin-bottom: 20px;
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 8px;
78
+ }
79
+
80
+ .card-title .badge {
81
+ font-size: 11px;
82
+ font-weight: 500;
83
+ padding: 2px 8px;
84
+ border-radius: 99px;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.5px;
87
+ }
88
+
89
+ .badge-set {
90
+ background: rgba(34, 197, 94, 0.15);
91
+ color: var(--success);
92
+ }
93
+
94
+ .badge-missing {
95
+ background: rgba(239, 68, 68, 0.15);
96
+ color: var(--error);
97
+ }
98
+
99
+ .form-group {
100
+ margin-bottom: 20px;
101
+ }
102
+
103
+ .form-group:last-child {
104
+ margin-bottom: 0;
105
+ }
106
+
107
+ label {
108
+ display: block;
109
+ font-size: 13px;
110
+ font-weight: 500;
111
+ color: var(--text-muted);
112
+ margin-bottom: 6px;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.5px;
115
+ }
116
+
117
+ .input-wrapper {
118
+ position: relative;
119
+ }
120
+
121
+ input[type="text"],
122
+ input[type="password"],
123
+ select {
124
+ width: 100%;
125
+ padding: 10px 14px;
126
+ background: var(--bg);
127
+ border: 1px solid var(--border);
128
+ border-radius: var(--radius-sm);
129
+ color: var(--text);
130
+ font-size: 14px;
131
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
132
+ transition: border-color 0.2s;
133
+ outline: none;
134
+ }
135
+
136
+ input:focus, select:focus {
137
+ border-color: var(--border-focus);
138
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
139
+ }
140
+
141
+ select {
142
+ cursor: pointer;
143
+ appearance: none;
144
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%238888a0' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 6l3.5 4 3.5-4z'/%3E%3C/svg%3E");
145
+ background-repeat: no-repeat;
146
+ background-position: right 12px center;
147
+ padding-right: 36px;
148
+ }
149
+
150
+ .toggle-visibility {
151
+ position: absolute;
152
+ right: 10px;
153
+ top: 50%;
154
+ transform: translateY(-50%);
155
+ background: none;
156
+ border: none;
157
+ color: var(--text-muted);
158
+ cursor: pointer;
159
+ padding: 4px;
160
+ font-size: 13px;
161
+ }
162
+
163
+ .toggle-visibility:hover {
164
+ color: var(--text);
165
+ }
166
+
167
+ .help-text {
168
+ font-size: 12px;
169
+ color: var(--text-muted);
170
+ margin-top: 6px;
171
+ }
172
+
173
+ .help-text a {
174
+ color: var(--primary);
175
+ text-decoration: none;
176
+ }
177
+
178
+ .help-text a:hover {
179
+ text-decoration: underline;
180
+ }
181
+
182
+ .btn-row {
183
+ display: flex;
184
+ gap: 10px;
185
+ margin-top: 24px;
186
+ }
187
+
188
+ button {
189
+ padding: 10px 20px;
190
+ border-radius: var(--radius-sm);
191
+ font-size: 14px;
192
+ font-weight: 500;
193
+ cursor: pointer;
194
+ border: none;
195
+ transition: all 0.2s;
196
+ display: inline-flex;
197
+ align-items: center;
198
+ gap: 6px;
199
+ }
200
+
201
+ .btn-primary {
202
+ background: var(--primary);
203
+ color: white;
204
+ flex: 1;
205
+ }
206
+
207
+ .btn-primary:hover {
208
+ background: var(--primary-hover);
209
+ }
210
+
211
+ .btn-primary:disabled {
212
+ opacity: 0.5;
213
+ cursor: not-allowed;
214
+ }
215
+
216
+ .btn-secondary {
217
+ background: var(--surface-hover);
218
+ color: var(--text);
219
+ border: 1px solid var(--border);
220
+ }
221
+
222
+ .btn-secondary:hover {
223
+ background: var(--border);
224
+ }
225
+
226
+ .btn-secondary:disabled {
227
+ opacity: 0.5;
228
+ cursor: not-allowed;
229
+ }
230
+
231
+ .toast {
232
+ position: fixed;
233
+ bottom: 24px;
234
+ left: 50%;
235
+ transform: translateX(-50%) translateY(80px);
236
+ padding: 12px 24px;
237
+ border-radius: var(--radius-sm);
238
+ font-size: 14px;
239
+ font-weight: 500;
240
+ opacity: 0;
241
+ transition: all 0.3s ease;
242
+ z-index: 1000;
243
+ white-space: nowrap;
244
+ }
245
+
246
+ .toast.show {
247
+ transform: translateX(-50%) translateY(0);
248
+ opacity: 1;
249
+ }
250
+
251
+ .toast-success {
252
+ background: rgba(34, 197, 94, 0.15);
253
+ color: var(--success);
254
+ border: 1px solid rgba(34, 197, 94, 0.3);
255
+ }
256
+
257
+ .toast-error {
258
+ background: rgba(239, 68, 68, 0.15);
259
+ color: var(--error);
260
+ border: 1px solid rgba(239, 68, 68, 0.3);
261
+ }
262
+
263
+ .voices-grid {
264
+ display: grid;
265
+ grid-template-columns: 1fr;
266
+ gap: 8px;
267
+ max-height: 300px;
268
+ overflow-y: auto;
269
+ margin-top: 12px;
270
+ padding-right: 4px;
271
+ }
272
+
273
+ .voices-grid::-webkit-scrollbar {
274
+ width: 6px;
275
+ }
276
+
277
+ .voices-grid::-webkit-scrollbar-track {
278
+ background: transparent;
279
+ }
280
+
281
+ .voices-grid::-webkit-scrollbar-thumb {
282
+ background: var(--border);
283
+ border-radius: 3px;
284
+ }
285
+
286
+ .voice-item {
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: space-between;
290
+ padding: 10px 14px;
291
+ background: var(--bg);
292
+ border: 1px solid var(--border);
293
+ border-radius: var(--radius-sm);
294
+ cursor: pointer;
295
+ transition: all 0.15s;
296
+ }
297
+
298
+ .voice-item:hover {
299
+ border-color: var(--border-focus);
300
+ background: var(--surface-hover);
301
+ }
302
+
303
+ .voice-item.selected {
304
+ border-color: var(--primary);
305
+ background: rgba(99, 102, 241, 0.08);
306
+ }
307
+
308
+ .voice-item .voice-info {
309
+ display: flex;
310
+ flex-direction: column;
311
+ }
312
+
313
+ .voice-item .voice-name {
314
+ font-size: 14px;
315
+ font-weight: 500;
316
+ }
317
+
318
+ .voice-item .voice-meta {
319
+ font-size: 12px;
320
+ color: var(--text-muted);
321
+ }
322
+
323
+ .voice-item .voice-actions {
324
+ display: flex;
325
+ gap: 6px;
326
+ align-items: center;
327
+ }
328
+
329
+ .btn-icon {
330
+ width: 32px;
331
+ height: 32px;
332
+ padding: 0;
333
+ display: flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ background: var(--surface);
337
+ border: 1px solid var(--border);
338
+ border-radius: 6px;
339
+ color: var(--text-muted);
340
+ cursor: pointer;
341
+ transition: all 0.15s;
342
+ font-size: 16px;
343
+ }
344
+
345
+ .btn-icon:hover {
346
+ color: var(--text);
347
+ border-color: var(--text-muted);
348
+ }
349
+
350
+ .spinner {
351
+ display: inline-block;
352
+ width: 16px;
353
+ height: 16px;
354
+ border: 2px solid transparent;
355
+ border-top-color: currentColor;
356
+ border-radius: 50%;
357
+ animation: spin 0.6s linear infinite;
358
+ }
359
+
360
+ @keyframes spin {
361
+ to { transform: rotate(360deg); }
362
+ }
363
+
364
+ .loading-state {
365
+ text-align: center;
366
+ padding: 32px 0;
367
+ color: var(--text-muted);
368
+ display: flex;
369
+ flex-direction: column;
370
+ align-items: center;
371
+ gap: 12px;
372
+ }
373
+
374
+ .empty-state {
375
+ text-align: center;
376
+ padding: 24px;
377
+ color: var(--text-muted);
378
+ font-size: 14px;
379
+ }
380
+
381
+ .env-notice {
382
+ font-size: 12px;
383
+ color: var(--text-muted);
384
+ background: rgba(245, 158, 11, 0.08);
385
+ border: 1px solid rgba(245, 158, 11, 0.2);
386
+ border-radius: var(--radius-sm);
387
+ padding: 10px 14px;
388
+ margin-top: 16px;
389
+ line-height: 1.5;
390
+ }
391
+
392
+ .env-notice strong {
393
+ color: var(--warning);
394
+ }
395
+
396
+ .presets-grid {
397
+ display: grid;
398
+ grid-template-columns: repeat(3, 1fr);
399
+ gap: 10px;
400
+ }
401
+
402
+ .preset-btn {
403
+ padding: 12px 14px;
404
+ background: var(--bg);
405
+ border: 1px solid var(--border);
406
+ border-radius: var(--radius-sm);
407
+ cursor: pointer;
408
+ transition: all 0.15s;
409
+ display: flex;
410
+ flex-direction: column;
411
+ align-items: flex-start;
412
+ gap: 2px;
413
+ text-align: left;
414
+ }
415
+
416
+ .preset-btn:hover {
417
+ border-color: var(--border-focus);
418
+ background: var(--surface-hover);
419
+ }
420
+
421
+ .preset-btn.active {
422
+ border-color: var(--primary);
423
+ background: rgba(99, 102, 241, 0.08);
424
+ }
425
+
426
+ .preset-name {
427
+ font-size: 13px;
428
+ font-weight: 600;
429
+ color: var(--text);
430
+ }
431
+
432
+ .preset-desc {
433
+ font-size: 11px;
434
+ color: var(--text-muted);
435
+ }
436
+
437
+ .slider-group {
438
+ margin-bottom: 20px;
439
+ }
440
+
441
+ .slider-group:last-of-type {
442
+ margin-bottom: 0;
443
+ }
444
+
445
+ .slider-label {
446
+ display: flex;
447
+ justify-content: space-between;
448
+ align-items: center;
449
+ margin-bottom: 8px;
450
+ }
451
+
452
+ .slider-label label {
453
+ margin-bottom: 0;
454
+ }
455
+
456
+ .slider-value {
457
+ font-size: 14px;
458
+ font-weight: 600;
459
+ color: var(--primary);
460
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
461
+ min-width: 36px;
462
+ text-align: right;
463
+ }
464
+
465
+ input[type="range"] {
466
+ -webkit-appearance: none;
467
+ appearance: none;
468
+ width: 100%;
469
+ height: 6px;
470
+ background: var(--bg);
471
+ border: 1px solid var(--border);
472
+ border-radius: 3px;
473
+ outline: none;
474
+ cursor: pointer;
475
+ }
476
+
477
+ input[type="range"]::-webkit-slider-thumb {
478
+ -webkit-appearance: none;
479
+ appearance: none;
480
+ width: 20px;
481
+ height: 20px;
482
+ border-radius: 50%;
483
+ background: var(--primary);
484
+ border: 2px solid var(--surface);
485
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
486
+ cursor: pointer;
487
+ transition: background 0.15s;
488
+ }
489
+
490
+ input[type="range"]::-webkit-slider-thumb:hover {
491
+ background: var(--primary-hover);
492
+ }
493
+
494
+ input[type="range"]::-moz-range-thumb {
495
+ width: 20px;
496
+ height: 20px;
497
+ border-radius: 50%;
498
+ background: var(--primary);
499
+ border: 2px solid var(--surface);
500
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
501
+ cursor: pointer;
502
+ }
503
+
504
+ .slider-range-labels {
505
+ display: flex;
506
+ justify-content: space-between;
507
+ font-size: 11px;
508
+ color: var(--text-muted);
509
+ margin-top: 4px;
510
+ }
511
+
512
+ .toggle-row {
513
+ display: flex;
514
+ align-items: center;
515
+ justify-content: space-between;
516
+ padding: 14px 0;
517
+ border-bottom: 1px solid var(--border);
518
+ margin-bottom: 16px;
519
+ }
520
+
521
+ .toggle-row:last-child {
522
+ border-bottom: none;
523
+ margin-bottom: 0;
524
+ }
525
+
526
+ .toggle-info {
527
+ display: flex;
528
+ flex-direction: column;
529
+ gap: 2px;
530
+ }
531
+
532
+ .toggle-title {
533
+ font-size: 14px;
534
+ font-weight: 500;
535
+ }
536
+
537
+ .toggle-desc {
538
+ font-size: 12px;
539
+ color: var(--text-muted);
540
+ }
541
+
542
+ .switch {
543
+ position: relative;
544
+ width: 44px;
545
+ height: 24px;
546
+ flex-shrink: 0;
547
+ }
548
+
549
+ .switch input {
550
+ opacity: 0;
551
+ width: 0;
552
+ height: 0;
553
+ }
554
+
555
+ .switch-slider {
556
+ position: absolute;
557
+ cursor: pointer;
558
+ inset: 0;
559
+ background: var(--border);
560
+ border-radius: 24px;
561
+ transition: background 0.2s;
562
+ }
563
+
564
+ .switch-slider::before {
565
+ content: '';
566
+ position: absolute;
567
+ width: 18px;
568
+ height: 18px;
569
+ left: 3px;
570
+ bottom: 3px;
571
+ background: var(--text);
572
+ border-radius: 50%;
573
+ transition: transform 0.2s;
574
+ }
575
+
576
+ .switch input:checked + .switch-slider {
577
+ background: var(--primary);
578
+ }
579
+
580
+ .switch input:checked + .switch-slider::before {
581
+ transform: translateX(20px);
582
+ }
583
+
584
+ .auto-submit-details {
585
+ overflow: hidden;
586
+ transition: max-height 0.3s ease, opacity 0.3s ease;
587
+ max-height: 0;
588
+ opacity: 0;
589
+ }
590
+
591
+ .auto-submit-details.open {
592
+ max-height: 1000px;
593
+ opacity: 1;
594
+ }
595
+
596
+ .status-indicator {
597
+ display: inline-flex;
598
+ align-items: center;
599
+ gap: 6px;
600
+ font-size: 12px;
601
+ padding: 4px 10px;
602
+ border-radius: 99px;
603
+ font-weight: 500;
604
+ }
605
+
606
+ .status-running {
607
+ background: rgba(34, 197, 94, 0.15);
608
+ color: var(--success);
609
+ }
610
+
611
+ .status-stopped {
612
+ background: rgba(136, 136, 160, 0.15);
613
+ color: var(--text-muted);
614
+ }
615
+
616
+ .status-dot {
617
+ width: 6px;
618
+ height: 6px;
619
+ border-radius: 50%;
620
+ background: currentColor;
621
+ }
622
+
623
+ .copy-btn {
624
+ position: absolute;
625
+ right: 8px;
626
+ top: 50%;
627
+ transform: translateY(-50%);
628
+ background: var(--surface-hover);
629
+ border: 1px solid var(--border);
630
+ border-radius: 4px;
631
+ padding: 4px 6px;
632
+ cursor: pointer;
633
+ color: var(--text-muted);
634
+ display: flex;
635
+ align-items: center;
636
+ transition: all 0.15s;
637
+ }
638
+
639
+ .copy-btn:hover {
640
+ background: var(--border);
641
+ color: var(--text);
642
+ }
643
+
644
+ .copy-btn:active {
645
+ transform: translateY(-50%) scale(0.95);
646
+ }
647
+
648
+ .reset-link {
649
+ font-size: 12px;
650
+ color: var(--text-muted);
651
+ background: none;
652
+ border: none;
653
+ cursor: pointer;
654
+ padding: 0;
655
+ text-decoration: underline;
656
+ display: inline;
657
+ font-weight: 400;
658
+ }
659
+
660
+ .reset-link:hover {
661
+ color: var(--text);
662
+ }
663
+
664
+ footer {
665
+ text-align: center;
666
+ margin-top: 40px;
667
+ color: var(--text-muted);
668
+ font-size: 13px;
669
+ }
670
+
671
+ footer a {
672
+ color: var(--primary);
673
+ text-decoration: none;
674
+ }
675
+ </style>
676
+ </head>
677
+ <body>
678
+ <div class="container">
679
+ <header>
680
+ <div class="icon">🔊</div>
681
+ <h1>Cursor TTS Settings</h1>
682
+ <p>Configure your text-to-speech MCP server</p>
683
+ </header>
684
+
685
+ <!-- API Key Card -->
686
+ <div class="card">
687
+ <div class="card-title">
688
+ API Key
689
+ <span id="apiKeyBadge" class="badge badge-missing">Not Set</span>
690
+ </div>
691
+
692
+ <div class="form-group">
693
+ <label for="apiKey">ElevenLabs API Key</label>
694
+ <div class="input-wrapper">
695
+ <input type="password" id="apiKey" placeholder="sk_xxxxxxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
696
+ <button class="toggle-visibility" id="toggleKey" type="button">Show</button>
697
+ </div>
698
+ <div class="help-text">
699
+ Get your key from <a href="https://elevenlabs.io/app/settings/api-keys" target="_blank">elevenlabs.io/app/settings/api-keys</a>
700
+ </div>
701
+ </div>
702
+
703
+ <div class="btn-row">
704
+ <button class="btn-secondary" id="testBtn" type="button">
705
+ Test Key
706
+ </button>
707
+ <button class="btn-primary" id="saveKeyBtn" type="button">
708
+ Save API Key
709
+ </button>
710
+ </div>
711
+ </div>
712
+
713
+ <!-- Voice Card -->
714
+ <div class="card">
715
+ <div class="card-title">Voice</div>
716
+
717
+ <div class="form-group">
718
+ <label for="voiceId">Voice ID</label>
719
+ <input type="text" id="voiceId" placeholder="21m00Tcm4TlvDq8ikWAM" spellcheck="false">
720
+ <div class="help-text">
721
+ Browse voices at <a href="https://elevenlabs.io/app/voice-library" target="_blank">elevenlabs.io/app/voice-library</a>
722
+ </div>
723
+ </div>
724
+
725
+ <div class="btn-row">
726
+ <button class="btn-secondary" id="loadVoicesBtn" type="button">
727
+ Load My Voices
728
+ </button>
729
+ <button class="btn-primary" id="saveVoiceBtn" type="button">
730
+ Save Voice
731
+ </button>
732
+ </div>
733
+
734
+ <div id="voicesList" style="display: none;">
735
+ <div class="voices-grid" id="voicesGrid"></div>
736
+ </div>
737
+ </div>
738
+
739
+ <!-- Model Card -->
740
+ <div class="card">
741
+ <div class="card-title">Model</div>
742
+
743
+ <div class="form-group">
744
+ <label for="model">TTS Model</label>
745
+ <select id="model">
746
+ <option value="eleven_flash_v2_5">Flash v2.5 (fastest, recommended)</option>
747
+ <option value="eleven_flash_v2">Flash v2</option>
748
+ <option value="eleven_multilingual_v2">Multilingual v2 (best quality)</option>
749
+ <option value="eleven_turbo_v2_5">Turbo v2.5</option>
750
+ <option value="eleven_turbo_v2">Turbo v2</option>
751
+ <option value="eleven_monolingual_v1">Monolingual v1</option>
752
+ </select>
753
+ </div>
754
+
755
+ <div class="btn-row">
756
+ <button class="btn-primary" id="saveModelBtn" type="button" style="flex: none;">
757
+ Save Model
758
+ </button>
759
+ </div>
760
+ </div>
761
+
762
+ <!-- Voice Settings Card -->
763
+ <div class="card">
764
+ <div class="card-title">
765
+ Voice Settings
766
+ <button class="reset-link" id="resetSettingsBtn" type="button">Reset to defaults</button>
767
+ </div>
768
+
769
+ <div class="form-group" style="margin-bottom: 24px;">
770
+ <label style="margin-bottom: 10px;">Quick Presets</label>
771
+ <div class="presets-grid">
772
+ <button class="preset-btn" data-preset="default" type="button">
773
+ <span class="preset-name">Default</span>
774
+ <span class="preset-desc">Balanced</span>
775
+ </button>
776
+ <button class="preset-btn" data-preset="fast" type="button">
777
+ <span class="preset-name">Fast</span>
778
+ <span class="preset-desc">Quick & energetic</span>
779
+ </button>
780
+ <button class="preset-btn" data-preset="slow" type="button">
781
+ <span class="preset-name">Slow</span>
782
+ <span class="preset-desc">Clear & measured</span>
783
+ </button>
784
+ <button class="preset-btn" data-preset="expressive" type="button">
785
+ <span class="preset-name">Expressive</span>
786
+ <span class="preset-desc">Dynamic & varied</span>
787
+ </button>
788
+ <button class="preset-btn" data-preset="stable" type="button">
789
+ <span class="preset-name">Stable</span>
790
+ <span class="preset-desc">Consistent tone</span>
791
+ </button>
792
+ <button class="preset-btn" data-preset="dramatic" type="button">
793
+ <span class="preset-name">Dramatic</span>
794
+ <span class="preset-desc">Maximum style</span>
795
+ </button>
796
+ </div>
797
+ </div>
798
+
799
+ <div class="slider-group">
800
+ <div class="slider-label">
801
+ <label for="speed">Speed</label>
802
+ <span class="slider-value" id="speedValue">1.00</span>
803
+ </div>
804
+ <input type="range" id="speed" min="0.7" max="1.2" step="0.05" value="1.0">
805
+ <div class="slider-range-labels">
806
+ <span>0.7x (slow)</span>
807
+ <span>1.2x (fast)</span>
808
+ </div>
809
+ <div class="help-text">Controls how fast the speech is delivered. 1.0 is normal speed.</div>
810
+ </div>
811
+
812
+ <div class="slider-group">
813
+ <div class="slider-label">
814
+ <label for="stability">Stability</label>
815
+ <span class="slider-value" id="stabilityValue">0.50</span>
816
+ </div>
817
+ <input type="range" id="stability" min="0" max="1" step="0.05" value="0.5">
818
+ <div class="slider-range-labels">
819
+ <span>More variable</span>
820
+ <span>More stable</span>
821
+ </div>
822
+ <div class="help-text">Higher values make the voice more consistent; lower adds more expressiveness.</div>
823
+ </div>
824
+
825
+ <div class="slider-group">
826
+ <div class="slider-label">
827
+ <label for="similarityBoost">Similarity Boost</label>
828
+ <span class="slider-value" id="similarityBoostValue">0.75</span>
829
+ </div>
830
+ <input type="range" id="similarityBoost" min="0" max="1" step="0.05" value="0.75">
831
+ <div class="slider-range-labels">
832
+ <span>Low</span>
833
+ <span>High</span>
834
+ </div>
835
+ <div class="help-text">Boosts similarity to the original voice. Higher values use more compute and may increase latency.</div>
836
+ </div>
837
+
838
+ <div class="slider-group">
839
+ <div class="slider-label">
840
+ <label for="style">Style Exaggeration</label>
841
+ <span class="slider-value" id="styleValue">0.00</span>
842
+ </div>
843
+ <input type="range" id="style" min="0" max="1" step="0.05" value="0">
844
+ <div class="slider-range-labels">
845
+ <span>None</span>
846
+ <span>Exaggerated</span>
847
+ </div>
848
+ <div class="help-text">Amplifies the style of the original speaker. Only works with V2+ models. May increase latency.</div>
849
+ </div>
850
+
851
+ <div class="btn-row">
852
+ <button class="btn-primary" id="saveSettingsBtn" type="button" style="flex: none;">
853
+ Save Voice Settings
854
+ </button>
855
+ </div>
856
+ </div>
857
+
858
+ <!-- Auto-Listen Card -->
859
+ <div class="card">
860
+ <div class="card-title">Auto-Listen</div>
861
+
862
+ <div class="toggle-row">
863
+ <div class="toggle-info">
864
+ <span class="toggle-title">Enable Auto-Listen</span>
865
+ <span class="toggle-desc">Automatically start listening for voice input after task completion</span>
866
+ </div>
867
+ <label class="switch">
868
+ <input type="checkbox" id="autoListenEnabled">
869
+ <span class="switch-slider"></span>
870
+ </label>
871
+ </div>
872
+
873
+ <p style="color: var(--text-muted); font-size: 13px; margin-top: 16px; line-height: 1.5;">
874
+ When enabled, I'll automatically call the listen tool after completing tasks to trigger the Wispr voice loop.
875
+ Disable this if you prefer to manually start dictation.
876
+ </p>
877
+ </div>
878
+
879
+ <!-- Auto-Submit Card -->
880
+ <div class="card">
881
+ <div class="card-title">Auto-Submit</div>
882
+
883
+ <div class="toggle-row">
884
+ <div class="toggle-info">
885
+ <span class="toggle-title">Enable Auto-Submit</span>
886
+ <span class="toggle-desc">Automatically press Enter when dictation ends</span>
887
+ </div>
888
+ <label class="switch">
889
+ <input type="checkbox" id="autoSubmitEnabled">
890
+ <span class="switch-slider"></span>
891
+ </label>
892
+ </div>
893
+
894
+ <div class="auto-submit-details" id="autoSubmitDetails">
895
+ <div class="slider-group">
896
+ <div class="slider-label">
897
+ <label for="silenceDelay">Silence Delay</label>
898
+ <span class="slider-value" id="silenceDelayValue">3.0s</span>
899
+ </div>
900
+ <input type="range" id="silenceDelay" min="0.5" max="5" step="0.25" value="3.0">
901
+ <div class="slider-range-labels">
902
+ <span>0.5s (instant)</span>
903
+ <span>5s (patient)</span>
904
+ </div>
905
+ <div class="help-text">How long to wait after typing stops before pressing Enter. Increase if prompts are getting cut off.</div>
906
+ </div>
907
+
908
+ <div class="slider-group">
909
+ <div class="slider-label">
910
+ <label for="minTextLength">Min Text Length</label>
911
+ <span class="slider-value" id="minTextLengthValue">15</span>
912
+ </div>
913
+ <input type="range" id="minTextLength" min="5" max="50" step="5" value="15">
914
+ <div class="slider-range-labels">
915
+ <span>5 chars (sensitive)</span>
916
+ <span>50 chars (strict)</span>
917
+ </div>
918
+ <div class="help-text">Minimum characters in clipboard to count as dictation. Prevents accidental submits from short copy-paste actions.</div>
919
+ </div>
920
+
921
+ <div class="btn-row" style="margin-bottom: 16px;">
922
+ <button class="btn-primary" id="saveAutoSubmitBtn" type="button" style="flex: none;">
923
+ Save Auto-Submit Settings
924
+ </button>
925
+ </div>
926
+
927
+ <div class="env-notice" style="margin-top: 0;">
928
+ <strong>How it works:</strong> Monitors the text field via Accessibility API. Detects text from dictation, typing, or paste. Auto-submits when Cursor is active.
929
+ <br><br>
930
+ <strong>Setup:</strong> After enabling and saving, run in a terminal:
931
+ <div style="position: relative; margin-top: 6px;">
932
+ <code style="display: block; padding: 6px 10px 6px 10px; background: var(--bg); border-radius: 4px; font-size: 13px; padding-right: 45px;">npm run auto-submit</code>
933
+ <button class="copy-btn" onclick="copyCommand()" title="Copy command">
934
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
935
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
936
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
937
+ </svg>
938
+ </button>
939
+ </div>
940
+ <span style="display: block; margin-top: 6px;">Requires macOS Accessibility permissions (System Settings &gt; Privacy &gt; Accessibility).</span>
941
+ </div>
942
+ </div>
943
+ </div>
944
+
945
+ <!-- Wispr Voice Loop Card -->
946
+ <div class="card">
947
+ <div class="card-title">Wispr Voice Loop</div>
948
+
949
+ <div class="toggle-row">
950
+ <div class="toggle-info">
951
+ <span class="toggle-title">Enable Wispr Voice Loop</span>
952
+ <span class="toggle-desc">Hands-free conversational loop with automatic Wispr control</span>
953
+ </div>
954
+ <label class="switch">
955
+ <input type="checkbox" id="wisprLoopEnabled">
956
+ <span class="switch-slider"></span>
957
+ </label>
958
+ </div>
959
+
960
+ <div class="auto-submit-details" id="wisprLoopDetails">
961
+ <div class="slider-group">
962
+ <div class="slider-label">
963
+ <label for="ttsDelay">TTS Delay</label>
964
+ <span class="slider-value" id="ttsDelayValue">4.0s</span>
965
+ </div>
966
+ <input type="range" id="ttsDelay" min="1" max="8" step="0.5" value="4.0">
967
+ <div class="slider-range-labels">
968
+ <span>1s (quick)</span>
969
+ <span>8s (patient)</span>
970
+ </div>
971
+ <div class="help-text">How long to wait for TTS to finish before starting Wispr. Increase if TTS gets cut off.</div>
972
+ </div>
973
+
974
+ <div class="slider-group">
975
+ <div class="slider-label">
976
+ <label for="silenceThreshold">Silence Threshold</label>
977
+ <span class="slider-value" id="silenceThresholdValue">0.020</span>
978
+ </div>
979
+ <input type="range" id="silenceThreshold" min="0.005" max="0.1" step="0.005" value="0.02">
980
+ <div class="slider-range-labels">
981
+ <span>0.005 (sensitive)</span>
982
+ <span>0.1 (strict)</span>
983
+ </div>
984
+ <div class="help-text">RMS amplitude threshold for speech detection. Lower = more sensitive to quiet speech.</div>
985
+ </div>
986
+
987
+ <div class="slider-group">
988
+ <div class="slider-label">
989
+ <label for="silenceDuration">Silence Duration</label>
990
+ <span class="slider-value" id="silenceDurationValue">2.0s</span>
991
+ </div>
992
+ <input type="range" id="silenceDuration" min="0.5" max="5" step="0.25" value="2.0">
993
+ <div class="slider-range-labels">
994
+ <span>0.5s (quick)</span>
995
+ <span>5s (patient)</span>
996
+ </div>
997
+ <div class="help-text">How long of silence confirms you're done speaking. Increase if it cuts you off mid-sentence.</div>
998
+ </div>
999
+
1000
+ <div class="form-group">
1001
+ <label for="wisprHotkey">Wispr Hotkey</label>
1002
+ <input type="text" id="wisprHotkey" value="shift+ctrl" spellcheck="false">
1003
+ <div class="help-text">Hotkey combo that toggles Wispr recording (start/stop). Default: shift+ctrl</div>
1004
+ </div>
1005
+
1006
+ <div class="form-group">
1007
+ <label for="manualTriggerHotkey">Manual Trigger Hotkey</label>
1008
+ <input type="text" id="manualTriggerHotkey" value="ctrl+shift+l" spellcheck="false">
1009
+ <div class="help-text">Global hotkey to manually start the voice loop anytime. Default: ctrl+shift+l</div>
1010
+ </div>
1011
+
1012
+ <div class="btn-row" style="margin-bottom: 16px;">
1013
+ <button class="btn-primary" id="saveWisprLoopBtn" type="button" style="flex: none;">
1014
+ Save Wispr Loop Settings
1015
+ </button>
1016
+ </div>
1017
+
1018
+ <div class="env-notice" style="margin-top: 0;">
1019
+ <strong>How it works:</strong>
1020
+ <ol style="margin: 8px 0 0 20px; padding: 0;">
1021
+ <li>Agent finishes task and calls listen() tool</li>
1022
+ <li>Script waits for TTS to finish, then triggers Wispr</li>
1023
+ <li>Your mic is monitored for speech and silence</li>
1024
+ <li>When silence is detected, Wispr stops and pastes</li>
1025
+ <li>Auto-submit presses Enter, cycle repeats</li>
1026
+ </ol>
1027
+ <span style="display: block; margin-top: 8px;">Requires sounddevice Python package and PortAudio. See terminal for setup commands.</span>
1028
+ </div>
1029
+ </div>
1030
+ </div>
1031
+
1032
+ <!-- Info Notice -->
1033
+ <div class="env-notice">
1034
+ <strong>Note:</strong> Environment variables (<code>ELEVENLABS_API_KEY</code>, <code>ELEVENLABS_VOICE_ID</code>) take priority over these settings.
1035
+ After saving, restart Cursor for the MCP server to pick up the new config.
1036
+ </div>
1037
+
1038
+ <footer>
1039
+ cursor-tts-mcp &middot; <a href="https://github.com" target="_blank">Documentation</a>
1040
+ </footer>
1041
+ </div>
1042
+
1043
+ <div class="toast" id="toast"></div>
1044
+
1045
+ <script>
1046
+ // Elements
1047
+ const apiKeyInput = document.getElementById('apiKey');
1048
+ const toggleKeyBtn = document.getElementById('toggleKey');
1049
+ const testBtn = document.getElementById('testBtn');
1050
+ const saveKeyBtn = document.getElementById('saveKeyBtn');
1051
+ const apiKeyBadge = document.getElementById('apiKeyBadge');
1052
+ const voiceIdInput = document.getElementById('voiceId');
1053
+ const loadVoicesBtn = document.getElementById('loadVoicesBtn');
1054
+ const saveVoiceBtn = document.getElementById('saveVoiceBtn');
1055
+ const voicesList = document.getElementById('voicesList');
1056
+ const voicesGrid = document.getElementById('voicesGrid');
1057
+ const modelSelect = document.getElementById('model');
1058
+ const saveModelBtn = document.getElementById('saveModelBtn');
1059
+ const speedSlider = document.getElementById('speed');
1060
+ const speedValue = document.getElementById('speedValue');
1061
+ const stabilitySlider = document.getElementById('stability');
1062
+ const stabilityValue = document.getElementById('stabilityValue');
1063
+ const similarityBoostSlider = document.getElementById('similarityBoost');
1064
+ const similarityBoostValue = document.getElementById('similarityBoostValue');
1065
+ const styleSlider = document.getElementById('style');
1066
+ const styleValue = document.getElementById('styleValue');
1067
+ const saveSettingsBtn = document.getElementById('saveSettingsBtn');
1068
+ const resetSettingsBtn = document.getElementById('resetSettingsBtn');
1069
+ const autoListenEnabled = document.getElementById('autoListenEnabled');
1070
+ const autoSubmitEnabled = document.getElementById('autoSubmitEnabled');
1071
+ const autoSubmitDetails = document.getElementById('autoSubmitDetails');
1072
+ const silenceDelaySlider = document.getElementById('silenceDelay');
1073
+ const silenceDelayValue = document.getElementById('silenceDelayValue');
1074
+ const minTextLengthSlider = document.getElementById('minTextLength');
1075
+ const minTextLengthValue = document.getElementById('minTextLengthValue');
1076
+ const saveAutoSubmitBtn = document.getElementById('saveAutoSubmitBtn');
1077
+ const wisprLoopEnabled = document.getElementById('wisprLoopEnabled');
1078
+ const wisprLoopDetails = document.getElementById('wisprLoopDetails');
1079
+ const ttsDelaySlider = document.getElementById('ttsDelay');
1080
+ const ttsDelayValue = document.getElementById('ttsDelayValue');
1081
+ const silenceThresholdSlider = document.getElementById('silenceThreshold');
1082
+ const silenceThresholdValue = document.getElementById('silenceThresholdValue');
1083
+ const silenceDurationSlider = document.getElementById('silenceDuration');
1084
+ const silenceDurationValue = document.getElementById('silenceDurationValue');
1085
+ const wisprHotkeyInput = document.getElementById('wisprHotkey');
1086
+ const manualTriggerHotkeyInput = document.getElementById('manualTriggerHotkey');
1087
+ const saveWisprLoopBtn = document.getElementById('saveWisprLoopBtn');
1088
+ const toastEl = document.getElementById('toast');
1089
+
1090
+ let currentPreviewAudio = null;
1091
+
1092
+ // Wire up slider value displays
1093
+ const sliders = [
1094
+ { slider: speedSlider, display: speedValue },
1095
+ { slider: stabilitySlider, display: stabilityValue },
1096
+ { slider: similarityBoostSlider, display: similarityBoostValue },
1097
+ { slider: styleSlider, display: styleValue },
1098
+ ];
1099
+ sliders.forEach(({ slider, display }) => {
1100
+ slider.addEventListener('input', () => {
1101
+ display.textContent = parseFloat(slider.value).toFixed(2);
1102
+ });
1103
+ });
1104
+
1105
+ // Auto-submit toggle and sliders
1106
+ // Auto-listen toggle - save immediately on change
1107
+ autoListenEnabled.addEventListener('change', async () => {
1108
+ try {
1109
+ const res = await fetch('/api/config', {
1110
+ method: 'POST',
1111
+ headers: { 'Content-Type': 'application/json' },
1112
+ body: JSON.stringify({ autoListen: autoListenEnabled.checked }),
1113
+ });
1114
+ const data = await res.json();
1115
+
1116
+ if (data.success) {
1117
+ showToast(autoListenEnabled.checked ? 'Auto-listen enabled' : 'Auto-listen disabled', 'success');
1118
+ }
1119
+ } catch (error) {
1120
+ showToast('Failed to save auto-listen setting', 'error');
1121
+ }
1122
+ });
1123
+
1124
+ autoSubmitEnabled.addEventListener('change', () => {
1125
+ autoSubmitDetails.classList.toggle('open', autoSubmitEnabled.checked);
1126
+ });
1127
+
1128
+ silenceDelaySlider.addEventListener('input', () => {
1129
+ silenceDelayValue.textContent = parseFloat(silenceDelaySlider.value).toFixed(1) + 's';
1130
+ });
1131
+
1132
+ minTextLengthSlider.addEventListener('input', () => {
1133
+ minTextLengthValue.textContent = parseInt(minTextLengthSlider.value);
1134
+ });
1135
+
1136
+ // Wispr loop toggle and sliders
1137
+ wisprLoopEnabled.addEventListener('change', () => {
1138
+ wisprLoopDetails.classList.toggle('open', wisprLoopEnabled.checked);
1139
+ });
1140
+
1141
+ ttsDelaySlider.addEventListener('input', () => {
1142
+ ttsDelayValue.textContent = parseFloat(ttsDelaySlider.value).toFixed(1) + 's';
1143
+ });
1144
+
1145
+ silenceThresholdSlider.addEventListener('input', () => {
1146
+ silenceThresholdValue.textContent = parseFloat(silenceThresholdSlider.value).toFixed(3);
1147
+ });
1148
+
1149
+ silenceDurationSlider.addEventListener('input', () => {
1150
+ silenceDurationValue.textContent = parseFloat(silenceDurationSlider.value).toFixed(1) + 's';
1151
+ });
1152
+
1153
+ // Voice settings presets
1154
+ const presets = {
1155
+ default: { speed: 1.0, stability: 0.5, similarityBoost: 0.75, style: 0.0 },
1156
+ fast: { speed: 1.15, stability: 0.6, similarityBoost: 0.75, style: 0.1 },
1157
+ slow: { speed: 0.85, stability: 0.7, similarityBoost: 0.8, style: 0.0 },
1158
+ expressive: { speed: 1.0, stability: 0.3, similarityBoost: 0.7, style: 0.3 },
1159
+ stable: { speed: 1.0, stability: 0.8, similarityBoost: 0.85, style: 0.0 },
1160
+ dramatic: { speed: 0.95, stability: 0.2, similarityBoost: 0.7, style: 0.6 },
1161
+ };
1162
+
1163
+ function applyPreset(presetName) {
1164
+ const preset = presets[presetName];
1165
+ if (!preset) return;
1166
+
1167
+ speedSlider.value = preset.speed;
1168
+ speedValue.textContent = preset.speed.toFixed(2);
1169
+ stabilitySlider.value = preset.stability;
1170
+ stabilityValue.textContent = preset.stability.toFixed(2);
1171
+ similarityBoostSlider.value = preset.similarityBoost;
1172
+ similarityBoostValue.textContent = preset.similarityBoost.toFixed(2);
1173
+ styleSlider.value = preset.style;
1174
+ styleValue.textContent = preset.style.toFixed(2);
1175
+
1176
+ // Update active state
1177
+ document.querySelectorAll('.preset-btn').forEach(btn => {
1178
+ btn.classList.toggle('active', btn.dataset.preset === presetName);
1179
+ });
1180
+
1181
+ showToast(`Applied ${presetName} preset - click Save to apply`, 'success');
1182
+ }
1183
+
1184
+ // Preset button click handlers
1185
+ document.querySelectorAll('.preset-btn').forEach(btn => {
1186
+ btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
1187
+ });
1188
+
1189
+ // Toggle API key visibility
1190
+ toggleKeyBtn.addEventListener('click', () => {
1191
+ const isPassword = apiKeyInput.type === 'password';
1192
+ apiKeyInput.type = isPassword ? 'text' : 'password';
1193
+ toggleKeyBtn.textContent = isPassword ? 'Hide' : 'Show';
1194
+ });
1195
+
1196
+ // Load current config on page load
1197
+ async function loadConfig() {
1198
+ try {
1199
+ const res = await fetch('/api/config');
1200
+ const data = await res.json();
1201
+
1202
+ if (data.apiKeySet) {
1203
+ apiKeyInput.placeholder = data.apiKey;
1204
+ apiKeyBadge.textContent = 'Set';
1205
+ apiKeyBadge.className = 'badge badge-set';
1206
+ } else {
1207
+ apiKeyBadge.textContent = 'Not Set';
1208
+ apiKeyBadge.className = 'badge badge-missing';
1209
+ }
1210
+
1211
+ voiceIdInput.value = data.voiceId || '';
1212
+ modelSelect.value = data.model || 'eleven_flash_v2_5';
1213
+
1214
+ // Auto-listen setting
1215
+ if (data.autoListen !== undefined) {
1216
+ autoListenEnabled.checked = data.autoListen;
1217
+ }
1218
+
1219
+ // Auto-submit settings
1220
+ if (data.autoSubmit) {
1221
+ const as = data.autoSubmit;
1222
+ autoSubmitEnabled.checked = !!as.enabled;
1223
+ autoSubmitDetails.classList.toggle('open', !!as.enabled);
1224
+ silenceDelaySlider.value = as.silenceDelay ?? 2.0;
1225
+ silenceDelayValue.textContent = parseFloat(silenceDelaySlider.value).toFixed(1) + 's';
1226
+ minTextLengthSlider.value = as.minTextLength ?? 10;
1227
+ minTextLengthValue.textContent = parseInt(minTextLengthSlider.value);
1228
+ }
1229
+
1230
+ // Voice settings sliders
1231
+ if (data.voiceSettings) {
1232
+ const vs = data.voiceSettings;
1233
+ speedSlider.value = vs.speed ?? 1.0;
1234
+ speedValue.textContent = parseFloat(speedSlider.value).toFixed(2);
1235
+ stabilitySlider.value = vs.stability ?? 0.5;
1236
+ stabilityValue.textContent = parseFloat(stabilitySlider.value).toFixed(2);
1237
+ similarityBoostSlider.value = vs.similarityBoost ?? 0.75;
1238
+ similarityBoostValue.textContent = parseFloat(similarityBoostSlider.value).toFixed(2);
1239
+ styleSlider.value = vs.style ?? 0.0;
1240
+ styleValue.textContent = parseFloat(styleSlider.value).toFixed(2);
1241
+ }
1242
+ } catch (error) {
1243
+ showToast('Failed to load config', 'error');
1244
+ }
1245
+ }
1246
+
1247
+ // Save API key
1248
+ saveKeyBtn.addEventListener('click', async () => {
1249
+ const apiKey = apiKeyInput.value.trim();
1250
+ if (!apiKey) {
1251
+ showToast('Please enter an API key', 'error');
1252
+ return;
1253
+ }
1254
+
1255
+ saveKeyBtn.disabled = true;
1256
+ saveKeyBtn.innerHTML = '<span class="spinner"></span> Saving...';
1257
+
1258
+ try {
1259
+ const res = await fetch('/api/config', {
1260
+ method: 'POST',
1261
+ headers: { 'Content-Type': 'application/json' },
1262
+ body: JSON.stringify({ apiKey }),
1263
+ });
1264
+ const data = await res.json();
1265
+
1266
+ if (data.success) {
1267
+ apiKeyInput.value = '';
1268
+ apiKeyInput.placeholder = data.config.apiKey;
1269
+ apiKeyBadge.textContent = 'Set';
1270
+ apiKeyBadge.className = 'badge badge-set';
1271
+ showToast('API key saved successfully', 'success');
1272
+ }
1273
+ } catch (error) {
1274
+ showToast('Failed to save API key', 'error');
1275
+ } finally {
1276
+ saveKeyBtn.disabled = false;
1277
+ saveKeyBtn.innerHTML = 'Save API Key';
1278
+ }
1279
+ });
1280
+
1281
+ // Test API key
1282
+ testBtn.addEventListener('click', async () => {
1283
+ const apiKey = apiKeyInput.value.trim();
1284
+
1285
+ testBtn.disabled = true;
1286
+ testBtn.innerHTML = '<span class="spinner"></span> Testing...';
1287
+
1288
+ try {
1289
+ const res = await fetch('/api/test', {
1290
+ method: 'POST',
1291
+ headers: { 'Content-Type': 'application/json' },
1292
+ body: JSON.stringify({ apiKey: apiKey || undefined }),
1293
+ });
1294
+ const data = await res.json();
1295
+
1296
+ if (data.success) {
1297
+ showToast(data.message, 'success');
1298
+ } else {
1299
+ showToast(data.error, 'error');
1300
+ }
1301
+ } catch (error) {
1302
+ showToast('Test request failed', 'error');
1303
+ } finally {
1304
+ testBtn.disabled = false;
1305
+ testBtn.innerHTML = 'Test Key';
1306
+ }
1307
+ });
1308
+
1309
+ // Save voice
1310
+ saveVoiceBtn.addEventListener('click', async () => {
1311
+ const voiceId = voiceIdInput.value.trim();
1312
+ if (!voiceId) {
1313
+ showToast('Please enter a voice ID', 'error');
1314
+ return;
1315
+ }
1316
+
1317
+ saveVoiceBtn.disabled = true;
1318
+ saveVoiceBtn.innerHTML = '<span class="spinner"></span> Saving...';
1319
+
1320
+ try {
1321
+ const res = await fetch('/api/config', {
1322
+ method: 'POST',
1323
+ headers: { 'Content-Type': 'application/json' },
1324
+ body: JSON.stringify({ voiceId }),
1325
+ });
1326
+ const data = await res.json();
1327
+
1328
+ if (data.success) {
1329
+ showToast('Voice saved successfully', 'success');
1330
+ // Update selection in voices list if visible
1331
+ updateVoiceSelection(voiceId);
1332
+ }
1333
+ } catch (error) {
1334
+ showToast('Failed to save voice', 'error');
1335
+ } finally {
1336
+ saveVoiceBtn.disabled = false;
1337
+ saveVoiceBtn.innerHTML = 'Save Voice';
1338
+ }
1339
+ });
1340
+
1341
+ // Load voices
1342
+ loadVoicesBtn.addEventListener('click', async () => {
1343
+ loadVoicesBtn.disabled = true;
1344
+ loadVoicesBtn.innerHTML = '<span class="spinner"></span> Loading...';
1345
+
1346
+ try {
1347
+ const res = await fetch('/api/voices', {
1348
+ method: 'POST',
1349
+ headers: { 'Content-Type': 'application/json' },
1350
+ body: JSON.stringify({}),
1351
+ });
1352
+ const data = await res.json();
1353
+
1354
+ if (data.error) {
1355
+ showToast(data.error, 'error');
1356
+ return;
1357
+ }
1358
+
1359
+ renderVoices(data.voices);
1360
+ voicesList.style.display = 'block';
1361
+ } catch (error) {
1362
+ showToast('Failed to load voices', 'error');
1363
+ } finally {
1364
+ loadVoicesBtn.disabled = false;
1365
+ loadVoicesBtn.innerHTML = 'Load My Voices';
1366
+ }
1367
+ });
1368
+
1369
+ function renderVoices(voices) {
1370
+ const currentVoice = voiceIdInput.value.trim();
1371
+
1372
+ if (!voices.length) {
1373
+ voicesGrid.innerHTML = '<div class="empty-state">No voices found. Add voices in your ElevenLabs dashboard.</div>';
1374
+ return;
1375
+ }
1376
+
1377
+ voicesGrid.innerHTML = voices.map(v => `
1378
+ <div class="voice-item ${v.id === currentVoice ? 'selected' : ''}" data-id="${v.id}">
1379
+ <div class="voice-info">
1380
+ <span class="voice-name">${escapeHtml(v.name)}</span>
1381
+ <span class="voice-meta">${escapeHtml(v.category || 'custom')} &middot; ${v.id.slice(0, 12)}...</span>
1382
+ </div>
1383
+ <div class="voice-actions">
1384
+ ${v.preview_url ? `<button class="btn-icon" title="Preview" onclick="event.stopPropagation(); previewVoice('${v.preview_url}')">&#9654;</button>` : ''}
1385
+ <button class="btn-icon" title="Select" onclick="event.stopPropagation(); selectVoice('${v.id}')">&#10003;</button>
1386
+ </div>
1387
+ </div>
1388
+ `).join('');
1389
+
1390
+ // Click to select
1391
+ voicesGrid.querySelectorAll('.voice-item').forEach(el => {
1392
+ el.addEventListener('click', () => selectVoice(el.dataset.id));
1393
+ });
1394
+ }
1395
+
1396
+ function selectVoice(id) {
1397
+ voiceIdInput.value = id;
1398
+ updateVoiceSelection(id);
1399
+ showToast('Voice selected - click Save Voice to apply', 'success');
1400
+ }
1401
+
1402
+ function updateVoiceSelection(id) {
1403
+ voicesGrid.querySelectorAll('.voice-item').forEach(el => {
1404
+ el.classList.toggle('selected', el.dataset.id === id);
1405
+ });
1406
+ }
1407
+
1408
+ function previewVoice(url) {
1409
+ if (currentPreviewAudio) {
1410
+ currentPreviewAudio.pause();
1411
+ currentPreviewAudio = null;
1412
+ }
1413
+ currentPreviewAudio = new Audio(url);
1414
+ currentPreviewAudio.play().catch(() => {
1415
+ showToast('Could not play preview', 'error');
1416
+ });
1417
+ }
1418
+
1419
+ // Save model
1420
+ saveModelBtn.addEventListener('click', async () => {
1421
+ saveModelBtn.disabled = true;
1422
+ saveModelBtn.innerHTML = '<span class="spinner"></span> Saving...';
1423
+
1424
+ try {
1425
+ const res = await fetch('/api/config', {
1426
+ method: 'POST',
1427
+ headers: { 'Content-Type': 'application/json' },
1428
+ body: JSON.stringify({ model: modelSelect.value }),
1429
+ });
1430
+ const data = await res.json();
1431
+
1432
+ if (data.success) {
1433
+ showToast('Model saved successfully', 'success');
1434
+ }
1435
+ } catch (error) {
1436
+ showToast('Failed to save model', 'error');
1437
+ } finally {
1438
+ saveModelBtn.disabled = false;
1439
+ saveModelBtn.innerHTML = 'Save Model';
1440
+ }
1441
+ });
1442
+
1443
+ // Save voice settings
1444
+ saveSettingsBtn.addEventListener('click', async () => {
1445
+ saveSettingsBtn.disabled = true;
1446
+ saveSettingsBtn.innerHTML = '<span class="spinner"></span> Saving...';
1447
+
1448
+ const voiceSettings = {
1449
+ speed: parseFloat(speedSlider.value),
1450
+ stability: parseFloat(stabilitySlider.value),
1451
+ similarityBoost: parseFloat(similarityBoostSlider.value),
1452
+ style: parseFloat(styleSlider.value),
1453
+ };
1454
+
1455
+ try {
1456
+ const res = await fetch('/api/config', {
1457
+ method: 'POST',
1458
+ headers: { 'Content-Type': 'application/json' },
1459
+ body: JSON.stringify({ voiceSettings }),
1460
+ });
1461
+ const data = await res.json();
1462
+
1463
+ if (data.success) {
1464
+ showToast('Voice settings saved successfully', 'success');
1465
+ }
1466
+ } catch (error) {
1467
+ showToast('Failed to save voice settings', 'error');
1468
+ } finally {
1469
+ saveSettingsBtn.disabled = false;
1470
+ saveSettingsBtn.innerHTML = 'Save Voice Settings';
1471
+ }
1472
+ });
1473
+
1474
+ // Reset voice settings to defaults
1475
+ resetSettingsBtn.addEventListener('click', () => {
1476
+ applyPreset('default');
1477
+ });
1478
+
1479
+ // Save auto-submit settings
1480
+ saveAutoSubmitBtn.addEventListener('click', async () => {
1481
+ saveAutoSubmitBtn.disabled = true;
1482
+ saveAutoSubmitBtn.innerHTML = '<span class="spinner"></span> Saving...';
1483
+
1484
+ const autoSubmit = {
1485
+ enabled: autoSubmitEnabled.checked,
1486
+ silenceDelay: parseFloat(silenceDelaySlider.value),
1487
+ minTextLength: parseInt(minTextLengthSlider.value),
1488
+ targetApp: 'Cursor',
1489
+ };
1490
+
1491
+ try {
1492
+ const res = await fetch('/api/config', {
1493
+ method: 'POST',
1494
+ headers: { 'Content-Type': 'application/json' },
1495
+ body: JSON.stringify({ autoSubmit }),
1496
+ });
1497
+ const data = await res.json();
1498
+
1499
+ if (data.success) {
1500
+ showToast('Auto-submit settings saved', 'success');
1501
+ }
1502
+ } catch (error) {
1503
+ showToast('Failed to save auto-submit settings', 'error');
1504
+ } finally {
1505
+ saveAutoSubmitBtn.disabled = false;
1506
+ saveAutoSubmitBtn.innerHTML = 'Save Auto-Submit Settings';
1507
+ }
1508
+ });
1509
+
1510
+ // Toast notification
1511
+ let toastTimeout;
1512
+ function showToast(message, type = 'success') {
1513
+ clearTimeout(toastTimeout);
1514
+ toastEl.textContent = message;
1515
+ toastEl.className = `toast toast-${type} show`;
1516
+ toastTimeout = setTimeout(() => {
1517
+ toastEl.classList.remove('show');
1518
+ }, 3500);
1519
+ }
1520
+
1521
+ function escapeHtml(str) {
1522
+ const div = document.createElement('div');
1523
+ div.textContent = str;
1524
+ return div.innerHTML;
1525
+ }
1526
+
1527
+ // Save wispr loop settings
1528
+ saveWisprLoopBtn.addEventListener('click', async () => {
1529
+ saveWisprLoopBtn.disabled = true;
1530
+ saveWisprLoopBtn.innerHTML = '<span class="spinner"></span> Saving...';
1531
+
1532
+ const wisprLoop = {
1533
+ enabled: wisprLoopEnabled.checked,
1534
+ ttsDelay: parseFloat(ttsDelaySlider.value),
1535
+ silenceThreshold: parseFloat(silenceThresholdSlider.value),
1536
+ silenceDuration: parseFloat(silenceDurationSlider.value),
1537
+ wisprHotkey: wisprHotkeyInput.value.trim(),
1538
+ manualTriggerHotkey: manualTriggerHotkeyInput.value.trim(),
1539
+ };
1540
+
1541
+ try {
1542
+ const res = await fetch('/api/config', {
1543
+ method: 'POST',
1544
+ headers: { 'Content-Type': 'application/json' },
1545
+ body: JSON.stringify({ wisprLoop }),
1546
+ });
1547
+ const data = await res.json();
1548
+
1549
+ if (data.success) {
1550
+ showToast('Wispr loop settings saved', 'success');
1551
+ }
1552
+ } catch (error) {
1553
+ showToast('Failed to save wispr loop settings', 'error');
1554
+ } finally {
1555
+ saveWisprLoopBtn.disabled = false;
1556
+ saveWisprLoopBtn.innerHTML = 'Save Wispr Loop Settings';
1557
+ }
1558
+ });
1559
+
1560
+ // Copy command to clipboard
1561
+ function copyCommand() {
1562
+ const command = 'npm run auto-submit';
1563
+ navigator.clipboard.writeText(command).then(() => {
1564
+ showToast('Command copied to clipboard', 'success');
1565
+ }).catch(() => {
1566
+ showToast('Failed to copy command', 'error');
1567
+ });
1568
+ }
1569
+
1570
+ // Init
1571
+ loadConfig();
1572
+ </script>
1573
+ </body>
1574
+ </html>