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,509 @@
1
+ /**
2
+ * HaloSmokeFace — Halo ring + wispy smoke core, audio-reactive.
3
+ *
4
+ * Reads from window.audioAnalyser (AnalyserNode) for real-time TTS audio data.
5
+ * Falls back to a gentle idle animation when no audio context is active.
6
+ *
7
+ * Exposes: window.HaloSmokeFace.start(container), window.HaloSmokeFace.stop()
8
+ */
9
+ window.HaloSmokeFace = (function () {
10
+ 'use strict';
11
+
12
+ const TAU = Math.PI * 2;
13
+ const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
14
+ const lerp = (a, b, t) => a + (b - a) * t;
15
+ const rand = (a = 0, b = 1) => a + Math.random() * (b - a);
16
+
17
+ // ── Perlin noise ──────────────────────────────────────────────────────────
18
+ const P = new Uint8Array(512);
19
+
20
+ function _initPerlin() {
21
+ const p = [];
22
+ for (let i = 0; i < 256; i++) p[i] = i;
23
+ for (let i = 255; i > 0; i--) {
24
+ const j = Math.floor(Math.random() * (i + 1));
25
+ [p[i], p[j]] = [p[j], p[i]];
26
+ }
27
+ for (let i = 0; i < 512; i++) P[i] = p[i & 255];
28
+ }
29
+
30
+ function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
31
+ function grad(h, x, y) { const v = h & 3; return ((v & 1) ? -x : x) + ((v & 2) ? -y : y); }
32
+ function perlin(x, y) {
33
+ const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
34
+ const xf = x - Math.floor(x), yf = y - Math.floor(y);
35
+ const u = fade(xf), v = fade(yf);
36
+ const aa = P[P[xi] + yi], ab = P[P[xi] + yi + 1];
37
+ const ba = P[P[xi + 1] + yi], bb = P[P[xi + 1] + yi + 1];
38
+ return lerp(
39
+ lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
40
+ lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
41
+ v
42
+ );
43
+ }
44
+ function fbm(x, y, oct = 4) {
45
+ let v = 0, a = 0.5, f = 1;
46
+ for (let i = 0; i < oct; i++) { v += a * perlin(x * f, y * f); f *= 2.1; a *= 0.48; }
47
+ return v;
48
+ }
49
+
50
+ // ── Settings ──────────────────────────────────────────────────────────────
51
+ const S = { quality: 'med', motion: 2.0, trails: 0.2, coreInt: 0.20, sensitivity: 2.10 };
52
+
53
+ // ── Audio feature smoothing state ─────────────────────────────────────────
54
+ let _sm = null;
55
+ let _fd = null;
56
+
57
+ function _resetSm() {
58
+ _sm = { amp: 0, bass: 0, mid: 0, treble: 0, kick: 0, drive: 0,
59
+ transient: 0, prevRms: 0, burstDecay: 0, es: 0, ema: 0.02 };
60
+ _fd = null;
61
+ }
62
+
63
+ function _getFeatures() {
64
+ const an = window.audioAnalyser;
65
+
66
+ if (!an) {
67
+ const t = performance.now() * 0.001;
68
+ if (_thinking) {
69
+ // Breathing pulse — dramatic slow oscillation while AI processes
70
+ const breath = (Math.sin(t * Math.PI) * 0.5 + 0.5); // ~2s cycle
71
+ const amp = 0.08 + breath * 0.27; // 0.08 – 0.35
72
+ return { amp, bass: amp * 0.9, mid: amp * 0.5, treble: amp * 0.3, kick: 0, drive: amp * 0.8, burst: 0, freq: null };
73
+ }
74
+ // Gentle idle pulse — calm swirling with no reactivity
75
+ const idle = (Math.sin(t * 0.9) * 0.5 + 0.5) * 0.06;
76
+ return { amp: idle, bass: idle, mid: idle * 0.5, treble: idle * 0.2, kick: 0, drive: idle * 0.8, burst: 0, freq: null };
77
+ }
78
+
79
+ // Resize frequency buffer if needed
80
+ if (!_fd || _fd.length !== an.frequencyBinCount) {
81
+ _fd = new Uint8Array(an.frequencyBinCount);
82
+ }
83
+ an.getByteFrequencyData(_fd);
84
+
85
+ // Time-domain RMS
86
+ const td = new Uint8Array(an.fftSize || 2048);
87
+ try { an.getByteTimeDomainData(td); } catch (_) {}
88
+
89
+ let sum = 0, peak = 0;
90
+ for (let i = 0; i < td.length; i++) {
91
+ const v = (td[i] - 128) / 128;
92
+ const av = Math.abs(v);
93
+ if (av > peak) peak = av;
94
+ sum += v * v;
95
+ }
96
+ const rms = Math.sqrt(sum / td.length);
97
+
98
+ const bins = _fd.length;
99
+ const ny = (an.context?.sampleRate || 48000) / 2;
100
+ const hi = hz => clamp(Math.round(hz / ny * bins), 0, bins - 1);
101
+ const avgRange = (a, b) => {
102
+ let s = 0, c = 0;
103
+ for (let i = a; i <= b; i++) { s += _fd[i]; c++; }
104
+ return (c ? s / c : 0) / 255;
105
+ };
106
+
107
+ const bass = avgRange(hi(20), hi(220));
108
+ const mid = avgRange(hi(220), hi(1500));
109
+ const tre = avgRange(hi(1500), hi(6500));
110
+
111
+ // Spectral energy + flux
112
+ let en = 0;
113
+ for (let i = 0; i < bins; i++) { const v = _fd[i] / 255; en += v * v; }
114
+ en /= bins;
115
+ _sm.es = lerp(_sm.es, en, 0.06);
116
+ const fl = Math.max(0, en - _sm.es);
117
+
118
+ // Speech transient detection
119
+ const rmsJump = Math.max(0, rms - _sm.prevRms);
120
+ _sm.transient = Math.max(_sm.transient * 0.82, rmsJump * 12);
121
+ _sm.prevRms = lerp(_sm.prevRms, rms, 0.15);
122
+
123
+ // Burst: combines flux + transient
124
+ _sm.burstDecay = Math.max(_sm.burstDecay * 0.88, clamp(_sm.transient + fl * 6, 0, 1));
125
+
126
+ // AGC amplitude
127
+ _sm.ema = lerp(_sm.ema, rms, 0.02);
128
+ const ag = 0.085 / Math.max(0.006, _sm.ema);
129
+ let ar2 = rms * ag * S.sensitivity;
130
+ ar2 = Math.max(ar2, peak * 0.55 * S.sensitivity);
131
+ let amp = clamp(ar2, 0, 2);
132
+ amp = Math.pow(amp, 0.62);
133
+ amp = clamp(amp, 0, 1);
134
+
135
+ const atkRate = 0.28, relRate = 0.08;
136
+ _sm.amp = amp > _sm.amp ? lerp(_sm.amp, amp, atkRate) : lerp(_sm.amp, amp, relRate);
137
+ _sm.bass = lerp(_sm.bass, bass, 0.14);
138
+ _sm.mid = lerp(_sm.mid, mid, 0.14);
139
+ _sm.treble = lerp(_sm.treble, tre, 0.14);
140
+
141
+ const kt = clamp(fl * 9, 0, 1);
142
+ _sm.kick = kt > _sm.kick ? lerp(_sm.kick, kt, 0.32) : lerp(_sm.kick, kt, 0.12);
143
+
144
+ const dt2 = clamp(_sm.amp * 0.85 + _sm.kick * 0.9, 0, 1);
145
+ _sm.drive = dt2 > _sm.drive ? lerp(_sm.drive, dt2, 0.25) : lerp(_sm.drive, dt2, 0.10);
146
+
147
+ return {
148
+ amp: _sm.amp, bass: _sm.bass, mid: _sm.mid, treble: _sm.treble,
149
+ kick: _sm.kick, drive: _sm.drive, burst: _sm.burstDecay, freq: _fd
150
+ };
151
+ }
152
+
153
+ // ── Visual state (reset on each start) ───────────────────────────────────
154
+ let _sparks = [];
155
+ let _wisps = [];
156
+ let _wispInited = false;
157
+ let _distortion = 0;
158
+ let _colorShock = 0;
159
+ let _spin = 0;
160
+ let _thinking = false;
161
+
162
+ function _initWisps() {
163
+ _wisps = [];
164
+ for (let i = 0; i < 28; i++) {
165
+ _wisps.push({
166
+ angle: rand(0, TAU),
167
+ radius: rand(0.15, 0.85),
168
+ speed: rand(0.15, 0.6) * (Math.random() > 0.5 ? 1 : -1),
169
+ width: rand(0.4, 1.8),
170
+ hueOff: rand(0, 360),
171
+ noiseOff: rand(0, 100),
172
+ opacity: rand(0.12, 0.35),
173
+ layer: Math.floor(rand(0, 3))
174
+ });
175
+ }
176
+ _wispInited = true;
177
+ }
178
+
179
+ function _spawnSpark(cx, cy, r, hue) {
180
+ _sparks.push({ a: rand(0, TAU), r, v: rand(2, 5.5), life: rand(0.3, 0.85), hue });
181
+ if (_sparks.length > 160) _sparks.splice(0, _sparks.length - 160);
182
+ }
183
+
184
+ // ── Main draw function (ported from voice-orb-halo-smoke2.html) ───────────
185
+ function _draw(ctx, t, dt, f, w, h) {
186
+ const cx = w * 0.5, cy = h * 0.5, base = Math.min(w, h) * 0.38;
187
+ if (!_wispInited) _initWisps();
188
+
189
+ const mo = S.motion, dr = f.drive, ki = f.kick, burst = f.burst || 0;
190
+
191
+ // Trail fade
192
+ ctx.globalCompositeOperation = 'source-over';
193
+ ctx.fillStyle = `rgba(6,8,14,${S.trails})`;
194
+ ctx.fillRect(0, 0, w, h);
195
+
196
+ // Distortion envelope
197
+ const distTarget = clamp(burst * 2.2 + ki * 1.8, 0, 1);
198
+ _distortion = distTarget > _distortion
199
+ ? lerp(_distortion, distTarget, 0.4)
200
+ : lerp(_distortion, distTarget, 0.06);
201
+
202
+ const shockTarget = clamp(burst * 3 + ki * 2, 0, 1);
203
+ _colorShock = shockTarget > _colorShock
204
+ ? lerp(_colorShock, shockTarget, 0.5)
205
+ : lerp(_colorShock, shockTarget, 0.04);
206
+
207
+ const hue0 = (t * 12 + _colorShock * 180 + ki * 120) % 360;
208
+ const ringR = base * (0.62 + dr * 0.05 + ki * 0.04);
209
+ const coreR = ringR * 0.88;
210
+ const dist = _distortion;
211
+ const ci = S.coreInt;
212
+ const calmHue = (hue0 + 200) % 360;
213
+
214
+ // ── Inner orb: ambient glow ──
215
+ ctx.save();
216
+ ctx.globalCompositeOperation = 'lighter';
217
+
218
+ const glowAlpha = 0.06 + dr * 0.08 + burst * 0.12;
219
+ const coreGlow = ctx.createRadialGradient(cx, cy, coreR * 0.02, cx, cy, coreR * 1.05);
220
+ coreGlow.addColorStop(0, `hsla(${calmHue},70%,65%,${glowAlpha + 0.04})`);
221
+ coreGlow.addColorStop(0.4, `hsla(${(calmHue + 40) % 360},80%,55%,${glowAlpha})`);
222
+ coreGlow.addColorStop(0.8, `hsla(${(calmHue + 90) % 360},60%,40%,${glowAlpha * 0.4})`);
223
+ coreGlow.addColorStop(1, 'rgba(0,0,0,0)');
224
+ ctx.fillStyle = coreGlow;
225
+ ctx.beginPath(); ctx.arc(cx, cy, coreR * 1.05, 0, TAU); ctx.fill();
226
+
227
+ // ── Wispy smoke strands ──
228
+ const segments = S.quality === 'high' ? 48 : S.quality === 'low' ? 24 : 36;
229
+
230
+ for (const ws of _wisps) {
231
+ const thinkBoost = _thinking ? 1.2 : 0;
232
+ ws.angle += dt * ws.speed * (0.3 + thinkBoost + dr * 1.5 + burst * 3.0) * mo;
233
+ const layerDepth = 0.4 + ws.layer * 0.25;
234
+
235
+ ctx.beginPath();
236
+ for (let i = 0; i <= segments; i++) {
237
+ const frac = i / segments;
238
+ const theta = ws.angle + frac * TAU * 0.6;
239
+ let r = coreR * ws.radius * layerDepth;
240
+
241
+ const nx = fbm(theta * 0.8 + ws.noiseOff + t * 0.15 * (1 + dist * 4), frac * 3 + t * 0.1, 3);
242
+ const ny = fbm(theta * 1.2 + ws.noiseOff * 0.7 + t * 0.12, frac * 2.5 - t * 0.08, 3);
243
+
244
+ r += nx * coreR * 0.22 * (0.5 + dist * 1.8) * ci;
245
+ if (dist > 0.05) {
246
+ const warpN = fbm(theta * 3.5 + t * 2.5 * dist + ws.noiseOff, frac * 5 + t * 1.5, 2);
247
+ r += warpN * coreR * 0.35 * dist * ci;
248
+ }
249
+
250
+ const swirl = theta + ny * 0.6 * (1 + dist * 2.5);
251
+ const x = cx + Math.cos(swirl) * r;
252
+ const y = cy + Math.sin(swirl) * r;
253
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
254
+ }
255
+
256
+ const wHue = (calmHue + ws.hueOff + _colorShock * 200 + dist * 160) % 360;
257
+ const wSat = 60 + dist * 35;
258
+ const wLit = 50 + dist * 18 + burst * 12;
259
+ const wAlpha = ws.opacity * (0.5 + dr * 1.2 + burst * 1.5) * ci;
260
+ ctx.strokeStyle = `hsla(${wHue},${wSat}%,${wLit}%,${clamp(wAlpha, 0, 0.6)})`;
261
+ ctx.lineWidth = ws.width * (1 + dist * 2.5 + dr * 1.2);
262
+ ctx.shadowColor = `hsla(${wHue},90%,60%,${clamp(wAlpha * 0.7, 0, 0.4)})`;
263
+ ctx.shadowBlur = 12 + dist * 25 + dr * 15;
264
+ ctx.stroke();
265
+ }
266
+
267
+ // ── Burst flares (speech transients) ──
268
+ if (burst > 0.15) {
269
+ const flareCount = Math.floor(3 + burst * 8);
270
+ for (let i = 0; i < flareCount; i++) {
271
+ const fa = rand(0, TAU);
272
+ const fr = coreR * rand(0.1, 0.7);
273
+ const fl2 = coreR * rand(0.05, 0.25) * burst;
274
+ const fhue = (hue0 + rand(-60, 60)) % 360;
275
+ ctx.strokeStyle = `hsla(${fhue},100%,70%,${burst * 0.35})`;
276
+ ctx.lineWidth = 0.8 + burst * 2;
277
+ ctx.shadowBlur = 8 + burst * 18;
278
+ ctx.shadowColor = `hsla(${fhue},100%,65%,${burst * 0.3})`;
279
+ ctx.beginPath();
280
+ ctx.moveTo(cx + Math.cos(fa) * fr, cy + Math.sin(fa) * fr);
281
+ ctx.lineTo(cx + Math.cos(fa) * (fr + fl2), cy + Math.sin(fa) * (fr + fl2));
282
+ ctx.stroke();
283
+ }
284
+ }
285
+
286
+ // ── Thinking: orbiting dots ──
287
+ if (_thinking) {
288
+ const orbitSpeed = 1.2;
289
+ const dotCount = 3;
290
+ const dotRadius = 4;
291
+ const trailLen = 0.35; // radians of trail arc
292
+ for (let i = 0; i < dotCount; i++) {
293
+ const baseAngle = t * orbitSpeed + (i / dotCount) * TAU;
294
+ const dx = cx + Math.cos(baseAngle) * ringR;
295
+ const dy = cy + Math.sin(baseAngle) * ringR;
296
+ const dotHue = (calmHue + i * 40) % 360;
297
+
298
+ // Comet trail
299
+ ctx.beginPath();
300
+ ctx.arc(cx, cy, ringR, baseAngle - trailLen, baseAngle, false);
301
+ ctx.strokeStyle = `hsla(${dotHue},80%,65%,0.25)`;
302
+ ctx.lineWidth = 3;
303
+ ctx.shadowColor = `hsla(${dotHue},90%,60%,0.3)`;
304
+ ctx.shadowBlur = 10;
305
+ ctx.stroke();
306
+
307
+ // Bright dot
308
+ ctx.beginPath();
309
+ ctx.arc(dx, dy, dotRadius, 0, TAU);
310
+ ctx.fillStyle = `hsla(${dotHue},85%,75%,0.9)`;
311
+ ctx.shadowColor = `hsla(${dotHue},100%,70%,0.8)`;
312
+ ctx.shadowBlur = 18;
313
+ ctx.fill();
314
+ }
315
+ ctx.shadowBlur = 0;
316
+ }
317
+
318
+ // ── Central bright core dot ──
319
+ const dotR = coreR * 0.06 * (1 + burst * 2.5 + dr * 0.8);
320
+ const dotGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(0.01, dotR));
321
+ dotGrad.addColorStop(0, `hsla(${(hue0 + 60) % 360},90%,85%,${0.15 + burst * 0.5 + dr * 0.2})`);
322
+ dotGrad.addColorStop(0.5, `hsla(${hue0},80%,65%,${0.08 + burst * 0.3})`);
323
+ dotGrad.addColorStop(1, 'rgba(0,0,0,0)');
324
+ ctx.fillStyle = dotGrad;
325
+ ctx.shadowBlur = 20 + burst * 40;
326
+ ctx.shadowColor = `hsla(${hue0},90%,70%,${0.2 + burst * 0.4})`;
327
+ ctx.beginPath(); ctx.arc(cx, cy, dotR, 0, TAU); ctx.fill();
328
+ ctx.shadowBlur = 0;
329
+ ctx.restore();
330
+
331
+ // ── Halo ring: frequency bars ──
332
+ const freq = f.freq;
333
+ const q = S.quality;
334
+ const bars = q === 'high' ? 200 : q === 'low' ? 110 : 160;
335
+ const step = freq ? Math.max(1, Math.floor(freq.length / bars)) : 1;
336
+
337
+ // Spawn sparks on kicks / bursts
338
+ if (ki > 0.20) {
339
+ const count = Math.floor(1 + (ki - 0.2) * 12);
340
+ for (let i = 0; i < count; i++) {
341
+ _spawnSpark(cx, cy, ringR + rand(-3, 6), (hue0 + rand(-30, 30)) % 360);
342
+ }
343
+ }
344
+ if (burst > 0.3) {
345
+ const count = Math.floor(burst * 5);
346
+ for (let i = 0; i < count; i++) {
347
+ _spawnSpark(cx, cy, ringR + rand(-5, 5), (hue0 + rand(-50, 50) + 180) % 360);
348
+ }
349
+ }
350
+
351
+ ctx.globalCompositeOperation = 'lighter';
352
+ ctx.lineCap = 'round';
353
+ _spin += dt * (0.15 + dr * 1.1 + ki * 2.0 + burst * 1.5) * mo;
354
+
355
+ // Blurred halo pass
356
+ ctx.filter = `blur(${(6 + dr * 16 + ki * 20).toFixed(1)}px)`;
357
+ for (let i = 0; i < bars; i++) {
358
+ const a = (i / bars) * TAU + _spin;
359
+ const mg = freq ? freq[i * step] / 255 : 0.04;
360
+ const len = base * (0.12 + mg * 0.55 * (0.65 + f.treble * 0.5 + dr * 0.45));
361
+ const hu = (hue0 + (i / bars) * 150 + f.treble * 120) % 360;
362
+ ctx.strokeStyle = `hsla(${hu},100%,64%,${0.06 + mg * 0.25 + dr * 0.08})`;
363
+ ctx.lineWidth = 2.5 + mg * 4.2 + dr * 1.5;
364
+ ctx.beginPath();
365
+ ctx.moveTo(cx + Math.cos(a) * ringR, cy + Math.sin(a) * ringR);
366
+ ctx.lineTo(cx + Math.cos(a) * (ringR + len), cy + Math.sin(a) * (ringR + len));
367
+ ctx.stroke();
368
+ }
369
+
370
+ // Crisp halo pass
371
+ ctx.filter = 'none';
372
+ for (let i = 0; i < bars; i++) {
373
+ const a = (i / bars) * TAU + _spin;
374
+ const mg = freq ? freq[i * step] / 255 : 0.04;
375
+ const len = base * (0.10 + mg * 0.48 * (0.65 + f.treble * 0.5));
376
+ const hu = (hue0 + (i / bars) * 160 + f.mid * 90) % 360;
377
+ ctx.strokeStyle = `hsla(${hu},100%,72%,${0.07 + mg * 0.38 + dr * 0.06})`;
378
+ ctx.lineWidth = 1.0 + mg * 2.4 + dr * 0.7;
379
+ ctx.beginPath();
380
+ ctx.moveTo(cx + Math.cos(a) * ringR, cy + Math.sin(a) * ringR);
381
+ ctx.lineTo(cx + Math.cos(a) * (ringR + len), cy + Math.sin(a) * (ringR + len));
382
+ ctx.stroke();
383
+ }
384
+
385
+ // Sparks
386
+ ctx.shadowBlur = 18 * (0.15 + dr + ki * 1.2);
387
+ for (let i = _sparks.length - 1; i >= 0; i--) {
388
+ const s = _sparks[i];
389
+ s.life -= dt * (0.75 + dr * 0.4);
390
+ s.a += dt * s.v * (1 + ki * 1.4 + burst * 0.8) * mo;
391
+ const al = clamp(s.life, 0, 1) * (0.08 + dr * 0.20 + ki * 0.18);
392
+ if (al <= 0) { _sparks.splice(i, 1); continue; }
393
+ ctx.shadowColor = `hsla(${s.hue},100%,70%,${al})`;
394
+ ctx.fillStyle = `hsla(${s.hue},100%,70%,${al})`;
395
+ ctx.beginPath();
396
+ ctx.arc(cx + Math.cos(s.a) * s.r, cy + Math.sin(s.a) * s.r, 1.3 + dr * 1.8 + burst * 1.2, 0, TAU);
397
+ ctx.fill();
398
+ }
399
+ ctx.shadowBlur = 0;
400
+ ctx.globalCompositeOperation = 'source-over';
401
+ }
402
+
403
+ // ── Animation loop ────────────────────────────────────────────────────────
404
+ let _canvas = null, _ctx = null, _container = null;
405
+ let _t0 = 0, _last = 0, _raf = null, _firstFrame = true;
406
+
407
+ function _loop(now) {
408
+ if (!_canvas) return;
409
+ _raf = requestAnimationFrame(_loop);
410
+
411
+ let dt = (now - _last) / 1000;
412
+ _last = now;
413
+ dt = Math.max(0.001, Math.min(dt, 0.05));
414
+ const t = (now - _t0) / 1000;
415
+
416
+ // Resize canvas to match its own CSS size (90% of face-box, circular)
417
+ const rect = _canvas.getBoundingClientRect();
418
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
419
+ const w = Math.max(2, Math.floor(rect.width * dpr));
420
+ const h = Math.max(2, Math.floor(rect.height * dpr));
421
+ if (_canvas.width !== w || _canvas.height !== h) {
422
+ _canvas.width = w;
423
+ _canvas.height = h;
424
+ }
425
+
426
+ // Fully opaque fill on first frame so nothing bleeds through
427
+ if (_firstFrame) {
428
+ _ctx.fillStyle = '#060810';
429
+ _ctx.fillRect(0, 0, w, h);
430
+ _firstFrame = false;
431
+ }
432
+
433
+ const f = _getFeatures();
434
+ _draw(_ctx, t, dt, f, w, h);
435
+ }
436
+
437
+ // ── Public API ────────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * Start the face inside the given container element (.face-box).
441
+ * @param {HTMLElement} container
442
+ */
443
+ function start(container) {
444
+ stop(); // clean up any previous run
445
+ _initPerlin();
446
+ _resetSm();
447
+
448
+ _container = container;
449
+
450
+ // Add class to hide the waveform mouth (face-box stays square)
451
+ const faceBox = document.getElementById('face-box');
452
+ if (faceBox) faceBox.classList.add('halo-smoke-mode');
453
+
454
+ // Hide the classic eyes while this face is active
455
+ const eyesEl = container.querySelector('.eyes-container');
456
+ if (eyesEl) eyesEl.style.display = 'none';
457
+
458
+ // Remove any existing orb canvas from legacy FaceRenderer
459
+ const oldOrb = container.querySelector('#orb-canvas');
460
+ if (oldOrb) oldOrb.remove();
461
+
462
+ // Create our canvas
463
+ _canvas = document.createElement('canvas');
464
+ _canvas.id = 'halo-smoke-canvas';
465
+ Object.assign(_canvas.style, {
466
+ position: 'absolute',
467
+ top: '0', left: '0',
468
+ width: '100%', height: '100%',
469
+ borderRadius: '50%',
470
+ pointerEvents: 'none',
471
+ background: '#060810',
472
+ zIndex: '20'
473
+ });
474
+ container.appendChild(_canvas);
475
+ _ctx = _canvas.getContext('2d', { alpha: true });
476
+
477
+ // Reset all visual state
478
+ _sparks = [];
479
+ _wisps = [];
480
+ _wispInited = false;
481
+ _distortion = 0;
482
+ _colorShock = 0;
483
+ _spin = 0;
484
+ _thinking = false;
485
+ _firstFrame = true;
486
+
487
+ _t0 = performance.now();
488
+ _last = _t0;
489
+ _raf = requestAnimationFrame(_loop);
490
+ }
491
+
492
+ /**
493
+ * Stop the face and remove the canvas from the DOM.
494
+ */
495
+ function stop() {
496
+ if (_raf) { cancelAnimationFrame(_raf); _raf = null; }
497
+ if (_canvas && _canvas.parentNode) _canvas.parentNode.removeChild(_canvas);
498
+ // Restore face-box to default shape
499
+ const faceBox = document.getElementById('face-box');
500
+ if (faceBox) faceBox.classList.remove('halo-smoke-mode');
501
+ _canvas = null;
502
+ _ctx = null;
503
+ _container = null;
504
+ }
505
+
506
+ function setThinking(v) { _thinking = !!v; }
507
+
508
+ return { start, stop, setThinking };
509
+ })();
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": 1,
3
+ "default": "eyes",
4
+ "faces": [
5
+ {
6
+ "id": "eyes",
7
+ "name": "AI Eyes",
8
+ "description": "Classic animated eyes with mood-based eyelid expressions and mouse tracking",
9
+ "module": "/src/face/EyeFace.js",
10
+ "class": "EyeFace",
11
+ "preview": "/src/face/previews/eyes.svg",
12
+ "moods": ["neutral", "happy", "sad", "angry", "thinking", "surprised", "listening"],
13
+ "features": ["blink", "eye-tracking", "mood-eyelids"]
14
+ },
15
+ {
16
+ "id": "halo-smoke",
17
+ "name": "Halo Smoke Orb",
18
+ "description": "Halo frequency ring with wispy smoke core — calm at rest, reacts to TTS speech",
19
+ "module": "/src/face/HaloSmokeFace.js",
20
+ "class": null,
21
+ "note": "Global script — use FaceRenderer.setMode('halo-smoke').",
22
+ "preview": "/src/face/previews/orb.svg",
23
+ "moods": [],
24
+ "features": ["audio-reactive", "smoke", "halo", "speech-reactive"]
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" width="120" height="80">
2
+ <rect width="120" height="80" fill="#060610"/>
3
+ <!-- Left eye -->
4
+ <ellipse cx="38" cy="40" rx="22" ry="20" fill="none" stroke="#0088ff" stroke-width="2.5"/>
5
+ <circle cx="38" cy="40" r="10" fill="#0088ff" opacity="0.8"/>
6
+ <circle cx="38" cy="40" r="5" fill="#00ffff"/>
7
+ <circle cx="41" cy="37" r="2" fill="white" opacity="0.7"/>
8
+ <!-- Right eye -->
9
+ <ellipse cx="82" cy="40" rx="22" ry="20" fill="none" stroke="#0088ff" stroke-width="2.5"/>
10
+ <circle cx="82" cy="40" r="10" fill="#0088ff" opacity="0.8"/>
11
+ <circle cx="82" cy="40" r="5" fill="#00ffff"/>
12
+ <circle cx="85" cy="37" r="2" fill="white" opacity="0.7"/>
13
+ <!-- Glow -->
14
+ <ellipse cx="38" cy="40" rx="22" ry="20" fill="none" stroke="#00ffff" stroke-width="1" opacity="0.3"/>
15
+ <ellipse cx="82" cy="40" rx="22" ry="20" fill="none" stroke="#00ffff" stroke-width="1" opacity="0.3"/>
16
+ </svg>
@@ -0,0 +1,29 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80" width="120" height="80">
2
+ <rect width="120" height="80" fill="#060610"/>
3
+ <!-- Outer glow -->
4
+ <circle cx="60" cy="40" r="36" fill="url(#orbGlow)" opacity="0.4"/>
5
+ <!-- Core orb -->
6
+ <circle cx="60" cy="40" r="26" fill="url(#orbCore)"/>
7
+ <!-- Border -->
8
+ <circle cx="60" cy="40" r="26" fill="none" stroke="#00ffff" stroke-width="1.5" opacity="0.7"/>
9
+ <!-- Particles -->
10
+ <circle cx="36" cy="30" r="2.5" fill="#00ffff" opacity="0.8"/>
11
+ <circle cx="86" cy="28" r="2" fill="#00ffff" opacity="0.6"/>
12
+ <circle cx="90" cy="52" r="3" fill="#0088ff" opacity="0.7"/>
13
+ <circle cx="32" cy="55" r="2" fill="#00ffff" opacity="0.5"/>
14
+ <circle cx="60" cy="14" r="2.5" fill="#0088ff" opacity="0.7"/>
15
+ <circle cx="60" cy="66" r="2" fill="#00ffff" opacity="0.6"/>
16
+ <!-- Highlight -->
17
+ <circle cx="50" cy="30" r="8" fill="white" opacity="0.12"/>
18
+ <defs>
19
+ <radialGradient id="orbCore" cx="40%" cy="35%" r="60%">
20
+ <stop offset="0%" stop-color="#00ffff" stop-opacity="0.9"/>
21
+ <stop offset="50%" stop-color="#0088ff" stop-opacity="0.6"/>
22
+ <stop offset="100%" stop-color="#0044aa" stop-opacity="0.4"/>
23
+ </radialGradient>
24
+ <radialGradient id="orbGlow" cx="50%" cy="50%" r="50%">
25
+ <stop offset="0%" stop-color="#0088ff" stop-opacity="0.5"/>
26
+ <stop offset="100%" stop-color="#0088ff" stop-opacity="0"/>
27
+ </radialGradient>
28
+ </defs>
29
+ </svg>