openvoiceui 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.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. package/tts_providers/supertonic_tts.py +399 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * SettingsPanel - Modular settings UI
3
+ * Theme picker, face mode selector, and other settings
4
+ */
5
+
6
+ window.SettingsPanel = {
7
+ isOpen: false,
8
+ currentPanel: null,
9
+ container: null,
10
+ _voicePreview: null, // TTSVoicePreview instance
11
+ _playlistEditor: null, // PlaylistEditor instance
12
+ _profileSwitcher: null, // ProfileSwitcher instance
13
+
14
+ init() {
15
+ // Create settings modal container
16
+ this.createContainer();
17
+
18
+ // Listen for escape key
19
+ document.addEventListener('keydown', (e) => {
20
+ if (e.key === 'Escape' && this.isOpen) {
21
+ this.close();
22
+ }
23
+ });
24
+ },
25
+
26
+ createContainer() {
27
+ // Check if already exists
28
+ if (document.getElementById('settings-panel-container')) return;
29
+
30
+ const container = document.createElement('div');
31
+ container.id = 'settings-panel-container';
32
+ container.innerHTML = `
33
+ <div class="settings-overlay" onclick="SettingsPanel.close()"></div>
34
+ <div class="settings-modal">
35
+ <div class="settings-header">
36
+ <h2>Settings</h2>
37
+ <button class="settings-close" onclick="SettingsPanel.close()">&times;</button>
38
+ </div>
39
+ <div class="settings-content" id="settings-content">
40
+ <!-- Dynamic content loaded here -->
41
+ </div>
42
+ </div>
43
+ `;
44
+ document.body.appendChild(container);
45
+ this.container = container;
46
+ },
47
+
48
+ open(panel = 'themes') {
49
+ if (!this.container) this.createContainer();
50
+
51
+ this.isOpen = true;
52
+ this.currentPanel = panel;
53
+ this.container.classList.add('open');
54
+
55
+ // Load panel content
56
+ this.loadPanel(panel);
57
+ },
58
+
59
+ close() {
60
+ this.isOpen = false;
61
+ if (this.container) {
62
+ this.container.classList.remove('open');
63
+ }
64
+ // Clean up voice preview player on close
65
+ if (this._voicePreview) {
66
+ this._voicePreview.destroy();
67
+ this._voicePreview = null;
68
+ }
69
+ // Clean up playlist editor on close
70
+ if (this._playlistEditor) {
71
+ this._playlistEditor.destroy();
72
+ this._playlistEditor = null;
73
+ }
74
+ // Clean up profile switcher on close
75
+ if (this._profileSwitcher) {
76
+ this._profileSwitcher.destroy();
77
+ this._profileSwitcher = null;
78
+ }
79
+ },
80
+
81
+ loadPanel(panel) {
82
+ const content = document.getElementById('settings-content');
83
+ if (!content) return;
84
+
85
+ switch (panel) {
86
+ case 'themes':
87
+ content.innerHTML = this.renderThemesPanel();
88
+ this.attachThemeListeners();
89
+ break;
90
+ case 'face':
91
+ content.innerHTML = this.renderFacePanel();
92
+ this.attachFaceListeners();
93
+ break;
94
+ case 'voice':
95
+ content.innerHTML = this.renderVoicePanel();
96
+ this.mountVoicePreview();
97
+ break;
98
+ case 'playlist':
99
+ content.innerHTML = this.renderPlaylistPanel();
100
+ this.mountPlaylistEditor();
101
+ break;
102
+ case 'profiles':
103
+ content.innerHTML = this.renderProfilesPanel();
104
+ this.mountProfileSwitcher();
105
+ break;
106
+ case 'full':
107
+ default:
108
+ content.innerHTML = this.renderFullPanel();
109
+ this.attachAllListeners();
110
+ break;
111
+ }
112
+ },
113
+
114
+ renderFullPanel() {
115
+ return `
116
+ <div class="settings-section">
117
+ <h3 onclick="SettingsPanel.toggleSection(this)">
118
+ <span class="section-arrow">&#9660;</span> Themes & Colors
119
+ </h3>
120
+ <div class="section-content">
121
+ ${this.renderThemesContent()}
122
+ </div>
123
+ </div>
124
+ <div class="settings-section">
125
+ <h3 onclick="SettingsPanel.toggleSection(this)">
126
+ <span class="section-arrow">&#9660;</span> Face Display
127
+ </h3>
128
+ <div class="section-content">
129
+ ${this.renderFaceContent()}
130
+ </div>
131
+ </div>
132
+ <div class="settings-section">
133
+ <h3 onclick="SettingsPanel.toggleSection(this)">
134
+ <span class="section-arrow">&#9660;</span> Voice Preview
135
+ </h3>
136
+ <div class="section-content">
137
+ <div id="voice-preview-root"><div class="tts-preview-loading">Loading voices\u2026</div></div>
138
+ </div>
139
+ </div>
140
+ <div class="settings-section">
141
+ <h3 onclick="SettingsPanel.toggleSection(this)">
142
+ <span class="section-arrow">&#9660;</span> Playlist Editor
143
+ </h3>
144
+ <div class="section-content">
145
+ <div id="playlist-editor-root"><div class="pe-loading">Loading playlist\u2026</div></div>
146
+ </div>
147
+ </div>
148
+ <div class="settings-section">
149
+ <h3 onclick="SettingsPanel.toggleSection(this)">
150
+ <span class="section-arrow">&#9660;</span> Agent Profiles
151
+ </h3>
152
+ <div class="section-content">
153
+ <div id="profile-switcher-root"><div class="ps-loading">Loading profiles\u2026</div></div>
154
+ </div>
155
+ </div>
156
+ `;
157
+ },
158
+
159
+ renderThemesPanel() {
160
+ return `
161
+ <div class="settings-section open">
162
+ <h3>Themes & Colors</h3>
163
+ <div class="section-content">
164
+ ${this.renderThemesContent()}
165
+ </div>
166
+ </div>
167
+ `;
168
+ },
169
+
170
+ renderThemesContent() {
171
+ const theme = window.ThemeManager?.getCurrentTheme() || {};
172
+ const presets = window.ThemeManager?.presets || {};
173
+
174
+ return `
175
+ <div class="theme-presets">
176
+ <label>Presets</label>
177
+ <div class="preset-grid">
178
+ ${Object.entries(presets).map(([name, colors]) => `
179
+ <button class="preset-btn" data-preset="${name}" style="background: linear-gradient(135deg, ${colors.primary}, ${colors.accent});">
180
+ ${name}
181
+ </button>
182
+ `).join('')}
183
+ </div>
184
+ </div>
185
+ <div class="theme-custom">
186
+ <label>Custom Colors</label>
187
+ <div class="color-pickers">
188
+ <div class="color-picker-group">
189
+ <label>Primary</label>
190
+ <input type="color" id="theme-primary" value="${theme.primary || '#0088ff'}">
191
+ </div>
192
+ <div class="color-picker-group">
193
+ <label>Accent</label>
194
+ <input type="color" id="theme-accent" value="${theme.accent || '#00ffff'}">
195
+ </div>
196
+ </div>
197
+ <button class="reset-btn" onclick="SettingsPanel.resetTheme()">Reset to Default</button>
198
+ </div>
199
+ `;
200
+ },
201
+
202
+ renderFacePanel() {
203
+ return `
204
+ <div class="settings-section open">
205
+ <h3>Face Display</h3>
206
+ <div class="section-content">
207
+ ${this.renderFaceContent()}
208
+ </div>
209
+ </div>
210
+ `;
211
+ },
212
+
213
+ renderFaceContent() {
214
+ // Kept for backwards compatibility — used when FacePicker is unavailable
215
+ const currentMode = window.FaceRenderer?.getCurrentMode() || 'eyes';
216
+ const modes = window.FaceRenderer?.getAvailableModes() || [];
217
+
218
+ return `
219
+ <div class="face-modes">
220
+ <label>Display Mode</label>
221
+ <div class="mode-grid">
222
+ ${modes.map(mode => `
223
+ <button class="mode-btn ${mode.id === currentMode ? 'active' : ''}" data-mode="${mode.id}">
224
+ <span class="mode-name">${mode.name}</span>
225
+ <span class="mode-desc">${mode.description}</span>
226
+ </button>
227
+ `).join('')}
228
+ </div>
229
+ </div>
230
+ `;
231
+ },
232
+
233
+ renderVoicePanel() {
234
+ return `
235
+ <div class="settings-section open">
236
+ <h3>Voice Preview</h3>
237
+ <div class="section-content">
238
+ <p class="tts-preview-hint">Click a voice to hear a sample.</p>
239
+ <div id="voice-preview-root"><div class="tts-preview-loading">Loading voices\u2026</div></div>
240
+ </div>
241
+ </div>
242
+ `;
243
+ },
244
+
245
+ renderPlaylistPanel() {
246
+ return `
247
+ <div class="settings-section open">
248
+ <h3>Playlist Editor</h3>
249
+ <div class="section-content">
250
+ <div id="playlist-editor-root"><div class="pe-loading">Loading playlist\u2026</div></div>
251
+ </div>
252
+ </div>
253
+ `;
254
+ },
255
+
256
+ async mountPlaylistEditor() {
257
+ const root = document.getElementById('playlist-editor-root');
258
+ if (!root) return;
259
+
260
+ if (this._playlistEditor) {
261
+ this._playlistEditor.destroy();
262
+ this._playlistEditor = null;
263
+ }
264
+
265
+ try {
266
+ const mod = await import('/src/ui/settings/PlaylistEditor.js');
267
+ const PlaylistEditor = mod.PlaylistEditor || mod.default;
268
+ this._playlistEditor = new PlaylistEditor();
269
+ await this._playlistEditor.mount(root);
270
+ } catch (err) {
271
+ console.error('[SettingsPanel] Failed to load PlaylistEditor:', err);
272
+ root.innerHTML = '<div class="pe-error">Playlist editor unavailable.</div>';
273
+ }
274
+ },
275
+
276
+ async mountVoicePreview() {
277
+ const root = document.getElementById('voice-preview-root');
278
+ if (!root) return;
279
+
280
+ // Destroy previous instance if any
281
+ if (this._voicePreview) {
282
+ this._voicePreview.destroy();
283
+ this._voicePreview = null;
284
+ }
285
+
286
+ // Try to load TTSVoicePreview module dynamically
287
+ try {
288
+ const mod = await import('/src/ui/settings/TTSVoicePreview.js');
289
+ const TTSVoicePreview = mod.TTSVoicePreview || mod.default;
290
+ this._voicePreview = new TTSVoicePreview();
291
+ await this._voicePreview.mount(root);
292
+ } catch (err) {
293
+ console.error('[SettingsPanel] Failed to load TTSVoicePreview:', err);
294
+ root.innerHTML = '<div class="tts-preview-error">Voice preview unavailable.</div>';
295
+ }
296
+ },
297
+
298
+ renderProfilesPanel() {
299
+ return `
300
+ <div class="settings-section open">
301
+ <h3>Agent Profiles</h3>
302
+ <div class="section-content">
303
+ <p class="ps-hint">Select a profile to switch the agent's personality, voice, and settings.</p>
304
+ <div id="profile-switcher-root"><div class="ps-loading">Loading profiles\u2026</div></div>
305
+ </div>
306
+ </div>
307
+ `;
308
+ },
309
+
310
+ async mountProfileSwitcher() {
311
+ const root = document.getElementById('profile-switcher-root');
312
+ if (!root) return;
313
+
314
+ if (this._profileSwitcher) {
315
+ this._profileSwitcher.destroy();
316
+ this._profileSwitcher = null;
317
+ }
318
+
319
+ try {
320
+ const mod = await import('/src/ui/ProfileSwitcher.js');
321
+ const ProfileSwitcher = mod.ProfileSwitcher || mod.default;
322
+ this._profileSwitcher = new ProfileSwitcher();
323
+ await this._profileSwitcher.mount(root);
324
+ } catch (err) {
325
+ console.error('[SettingsPanel] Failed to load ProfileSwitcher:', err);
326
+ root.innerHTML = '<div class="ps-error">Profile switcher unavailable.</div>';
327
+ }
328
+ },
329
+
330
+ attachAllListeners() {
331
+ this.attachThemeListeners();
332
+ this.attachFaceListeners();
333
+ // Voice preview mounts async after render — trigger it now if the root exists
334
+ const voiceRoot = document.getElementById('voice-preview-root');
335
+ if (voiceRoot) {
336
+ this.mountVoicePreview();
337
+ }
338
+ // Playlist editor mounts async
339
+ const playlistRoot = document.getElementById('playlist-editor-root');
340
+ if (playlistRoot) {
341
+ this.mountPlaylistEditor();
342
+ }
343
+ // Profile switcher mounts async
344
+ const profileRoot = document.getElementById('profile-switcher-root');
345
+ if (profileRoot) {
346
+ this.mountProfileSwitcher();
347
+ }
348
+ },
349
+
350
+ attachThemeListeners() {
351
+ // Color picker listeners
352
+ const primaryPicker = document.getElementById('theme-primary');
353
+ const accentPicker = document.getElementById('theme-accent');
354
+
355
+ if (primaryPicker) {
356
+ primaryPicker.addEventListener('input', (e) => {
357
+ window.ThemeManager?.setPrimaryColor(e.target.value);
358
+ });
359
+ }
360
+
361
+ if (accentPicker) {
362
+ accentPicker.addEventListener('input', (e) => {
363
+ window.ThemeManager?.setAccentColor(e.target.value);
364
+ });
365
+ }
366
+
367
+ // Preset buttons
368
+ document.querySelectorAll('.preset-btn').forEach(btn => {
369
+ btn.addEventListener('click', () => {
370
+ const preset = btn.dataset.preset;
371
+ window.ThemeManager?.applyPreset(preset);
372
+
373
+ // Update color pickers
374
+ const theme = window.ThemeManager?.getCurrentTheme();
375
+ if (primaryPicker && theme) primaryPicker.value = theme.primary;
376
+ if (accentPicker && theme) accentPicker.value = theme.accent;
377
+ });
378
+ });
379
+ },
380
+
381
+ attachFaceListeners() {
382
+ const root = document.getElementById('face-picker-root');
383
+ if (root && window.FacePicker) {
384
+ window.FacePicker.mount(root);
385
+ return;
386
+ }
387
+
388
+ // Legacy fallback: plain mode buttons (FacePicker not loaded)
389
+ document.querySelectorAll('.mode-btn').forEach(btn => {
390
+ btn.addEventListener('click', () => {
391
+ const mode = btn.dataset.mode;
392
+ window.FaceRenderer?.setMode(mode);
393
+
394
+ document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
395
+ btn.classList.add('active');
396
+ });
397
+ });
398
+ },
399
+
400
+ toggleSection(header) {
401
+ const section = header.parentElement;
402
+ section.classList.toggle('open');
403
+ const arrow = header.querySelector('.section-arrow');
404
+ if (arrow) {
405
+ arrow.innerHTML = section.classList.contains('open') ? '&#9660;' : '&#9654;';
406
+ }
407
+ },
408
+
409
+ resetTheme() {
410
+ window.ThemeManager?.resetTheme();
411
+
412
+ // Update pickers
413
+ const theme = window.ThemeManager?.getCurrentTheme();
414
+ const primaryPicker = document.getElementById('theme-primary');
415
+ const accentPicker = document.getElementById('theme-accent');
416
+ if (primaryPicker && theme) primaryPicker.value = theme.primary;
417
+ if (accentPicker && theme) accentPicker.value = theme.accent;
418
+ }
419
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * TTSVoicePreview — Voice picker with live audio preview (P4-T5)
3
+ *
4
+ * Fetches available TTS providers + voices from /api/tts/providers,
5
+ * renders a voice grid with play buttons, and calls /api/tts/preview
6
+ * to generate short audio samples played via TTSPlayer.
7
+ *
8
+ * Usage (standalone):
9
+ * import { TTSVoicePreview } from './settings/TTSVoicePreview.js';
10
+ * const preview = new TTSVoicePreview();
11
+ * preview.mount(document.getElementById('my-container'));
12
+ *
13
+ * Usage (via SettingsPanel):
14
+ * SettingsPanel.open('voice');
15
+ */
16
+
17
+ export class TTSVoicePreview {
18
+ constructor() {
19
+ this._player = null;
20
+ this._root = null;
21
+ this._activeBtn = null;
22
+ this._loading = false;
23
+ }
24
+
25
+ // -------------------------------------------------------------------------
26
+ // Public API
27
+ // -------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Mount the voice preview widget into `container` (replaces contents).
31
+ * @param {HTMLElement} container
32
+ */
33
+ async mount(container) {
34
+ this._root = container;
35
+ this._root.innerHTML = '<div class="tts-preview-loading">Loading voices\u2026</div>';
36
+
37
+ let providers;
38
+ try {
39
+ const resp = await fetch('/api/tts/providers');
40
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
41
+ providers = await resp.json();
42
+ } catch (err) {
43
+ this._root.innerHTML = `<div class="tts-preview-error">Failed to load voices: ${err.message}</div>`;
44
+ return;
45
+ }
46
+
47
+ this._root.innerHTML = this._render(providers);
48
+ this._attachListeners();
49
+ }
50
+
51
+ destroy() {
52
+ if (this._player) {
53
+ this._player.stop();
54
+ this._player = null;
55
+ }
56
+ if (this._root) {
57
+ this._root.innerHTML = '';
58
+ this._root = null;
59
+ }
60
+ }
61
+
62
+ // -------------------------------------------------------------------------
63
+ // Rendering
64
+ // -------------------------------------------------------------------------
65
+
66
+ _render(providersData) {
67
+ const providers = providersData.providers || {};
68
+ const defaultProvider = providersData.default_provider || 'supertonic';
69
+
70
+ const sections = Object.entries(providers)
71
+ .filter(([, p]) => p.mode !== 'full-voice' && Array.isArray(p.voices) && p.voices.length > 0)
72
+ .map(([id, p]) => this._renderProvider(id, p, id === defaultProvider))
73
+ .join('');
74
+
75
+ if (!sections) {
76
+ return '<div class="tts-preview-empty">No previewable TTS voices found.</div>';
77
+ }
78
+
79
+ return `<div class="tts-voice-preview">${sections}</div>`;
80
+ }
81
+
82
+ _renderProvider(id, provider, isDefault) {
83
+ const voices = provider.voices || [];
84
+ const voiceCards = voices.map(v => this._renderVoiceCard(id, v)).join('');
85
+ return `
86
+ <div class="tts-provider-section">
87
+ <div class="tts-provider-header">
88
+ <span class="tts-provider-name">${this._esc(provider.name || id)}</span>
89
+ ${isDefault ? '<span class="tts-provider-badge">default</span>' : ''}
90
+ <span class="tts-provider-meta">${this._esc(provider.description || '')}</span>
91
+ </div>
92
+ <div class="tts-voice-grid">${voiceCards}</div>
93
+ </div>
94
+ `;
95
+ }
96
+
97
+ _renderVoiceCard(providerId, voice) {
98
+ return `
99
+ <button
100
+ class="tts-voice-card"
101
+ data-provider="${this._esc(providerId)}"
102
+ data-voice="${this._esc(voice)}"
103
+ title="Preview voice ${this._esc(voice)}"
104
+ >
105
+ <span class="tts-voice-play-icon">&#9654;</span>
106
+ <span class="tts-voice-name">${this._esc(voice)}</span>
107
+ </button>
108
+ `;
109
+ }
110
+
111
+ // -------------------------------------------------------------------------
112
+ // Event handling
113
+ // -------------------------------------------------------------------------
114
+
115
+ _attachListeners() {
116
+ if (!this._root) return;
117
+ this._root.addEventListener('click', (e) => {
118
+ const card = e.target.closest('.tts-voice-card');
119
+ if (card && !this._loading) {
120
+ this._previewVoice(card);
121
+ }
122
+ });
123
+ }
124
+
125
+ async _previewVoice(btn) {
126
+ const provider = btn.dataset.provider;
127
+ const voice = btn.dataset.voice;
128
+
129
+ // Stop any running preview
130
+ if (this._player) {
131
+ this._player.stop();
132
+ }
133
+
134
+ // Visual: mark active
135
+ if (this._activeBtn) {
136
+ this._activeBtn.classList.remove('tts-voice-card--playing', 'tts-voice-card--loading');
137
+ }
138
+ this._activeBtn = btn;
139
+ btn.classList.add('tts-voice-card--loading');
140
+ this._loading = true;
141
+
142
+ try {
143
+ const resp = await fetch('/api/tts/preview', {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ provider, voice }),
147
+ });
148
+
149
+ if (!resp.ok) {
150
+ const err = await resp.json().catch(() => ({}));
151
+ throw new Error(err.error || `HTTP ${resp.status}`);
152
+ }
153
+
154
+ const { audio_b64 } = await resp.json();
155
+
156
+ btn.classList.remove('tts-voice-card--loading');
157
+ btn.classList.add('tts-voice-card--playing');
158
+
159
+ await this._playAudio(audio_b64);
160
+
161
+ } catch (err) {
162
+ console.error('[TTSVoicePreview] Preview failed:', err);
163
+ this._showError(btn, err.message);
164
+ } finally {
165
+ btn.classList.remove('tts-voice-card--loading', 'tts-voice-card--playing');
166
+ this._loading = false;
167
+ }
168
+ }
169
+
170
+ async _playAudio(audio_b64) {
171
+ // Use TTSPlayer if available (imported or on window), else HTMLAudio fallback
172
+ const TTSPlayerClass = window.TTSPlayer;
173
+ if (TTSPlayerClass) {
174
+ if (!this._player) {
175
+ this._player = new TTSPlayerClass();
176
+ }
177
+ await this._player.init();
178
+ await this._player.play(audio_b64);
179
+ } else {
180
+ // Fallback: HTMLAudioElement
181
+ const binary = atob(audio_b64);
182
+ const bytes = new Uint8Array(binary.length);
183
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
184
+ const blob = new Blob([bytes], { type: 'audio/wav' });
185
+ const url = URL.createObjectURL(blob);
186
+ const audio = new Audio(url);
187
+ await new Promise((resolve) => {
188
+ audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
189
+ audio.onerror = () => { URL.revokeObjectURL(url); resolve(); };
190
+ audio.play().catch(resolve);
191
+ });
192
+ }
193
+ }
194
+
195
+ _showError(btn, message) {
196
+ btn.title = `Error: ${message}`;
197
+ btn.classList.add('tts-voice-card--error');
198
+ setTimeout(() => btn.classList.remove('tts-voice-card--error'), 3000);
199
+ }
200
+
201
+ _esc(str) {
202
+ return String(str)
203
+ .replace(/&/g, '&amp;')
204
+ .replace(/</g, '&lt;')
205
+ .replace(/>/g, '&gt;')
206
+ .replace(/"/g, '&quot;');
207
+ }
208
+ }
209
+
210
+ export default TTSVoicePreview;