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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- 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()">×</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">▼</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">▼</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">▼</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">▼</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">▼</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') ? '▼' : '▶';
|
|
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">▶</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, '&')
|
|
204
|
+
.replace(/</g, '<')
|
|
205
|
+
.replace(/>/g, '>')
|
|
206
|
+
.replace(/"/g, '"');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default TTSVoicePreview;
|