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,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>
|