@turmind/halo 1.5.7 → 1.5.9
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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +1 -0
- package/admin-out/_next/static/chunks/597-399e2e1de26aa747.js +1 -0
- package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +1 -0
- package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
- package/admin-out/_next/static/css/27b44c85485eabb9.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/architecture.md +3 -2
- package/bundled-docs/design/canvas-extensions.md +51 -11
- package/bundled-docs/design/cron.md +6 -6
- package/bundled-docs/design/evolution.md +47 -15
- package/bundled-docs/design/express-self.md +96 -20
- package/bundled-docs/design/storage.md +10 -2
- package/bundled-docs/design/ws.md +1 -0
- package/bundled-docs/dev/add-model-provider.md +16 -7
- package/bundled-docs/dev/api.md +7 -6
- package/bundled-docs/dev/desktop-packaging.md +26 -0
- package/bundled-docs/guide/agents.md +3 -3
- package/bundled-docs/guide/chat.md +3 -0
- package/bundled-docs/guide/cli.md +10 -1
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +17 -9
- package/bundled-docs/requirements/editor.md +3 -1
- package/bundled-docs/requirements/explorer.md +4 -2
- package/bundled-docs/requirements/settings.md +11 -3
- package/bundled-docs/requirements/workspace.md +2 -2
- package/dist/index.js +2514 -1669
- package/package.json +2 -1
- package/templates/agents/__evo_agent__/AGENT.md +7 -0
- package/templates/agents/__score__/AGENT.md +183 -71
- package/templates/canvas/self.html +617 -71
- package/templates/models/anthropic.yaml +1 -0
- package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
- package/templates/models/aws-bedrock-mantle.yaml +1 -0
- package/templates/models/aws-bedrock-openai.yaml +1 -0
- package/templates/models/deepseek.yaml +1 -0
- package/templates/models/doubao.yaml +1 -0
- package/templates/models/hunyuan.yaml +1 -0
- package/templates/models/kimi.yaml +1 -0
- package/templates/models/mimo-token-plan-china.yaml +1 -0
- package/templates/models/minimax.yaml +1 -0
- package/templates/models/openai.yaml +1 -0
- package/templates/models/qwen.yaml +1 -0
- package/templates/models/zhipu.yaml +1 -0
- package/templates/skills/cron/SKILL.md +59 -4
- package/templates/skills/cron/manage-cron.py +158 -24
- package/templates/skills/extension/SKILL.md +30 -2
- package/templates/skills/extension/config.yaml +2 -2
- package/templates/skills/extension/templates/ext.sh +69 -16
- package/templates/skills/self/SKILL.md +79 -17
- package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
- package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
- package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
- package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
- /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
ripples to its ACTUAL amplitude: the core swells with loudness,
|
|
25
25
|
a spectrum halo dances to the timbre, each syllable leaves a
|
|
26
26
|
ring. Not a canned animation — the face listening to my voice.
|
|
27
|
+
• show() — the dots fly in and gather into a picture's outline, then the
|
|
28
|
+
real image fades in over them (dots alone can't carry a chart's
|
|
29
|
+
text), holds, and fades out as the dots let go.
|
|
27
30
|
|
|
28
31
|
── how I reach it ──────────────────────────────────────────────────────
|
|
29
32
|
The world speaks to this page through ONE door: a postMessage carrying a
|
|
@@ -38,13 +41,26 @@
|
|
|
38
41
|
v3 voice(url): I play speech Halo synthesized and the field rides its
|
|
39
42
|
live amplitude — Web Audio analyser → loudness, spectrum, syllables.
|
|
40
43
|
Halo makes the sound; this file makes it visible. (MODES.wave is real.)
|
|
44
|
+
v4 show(path, ms) + the `show` play() beat: a workspace image (png / jpg /
|
|
45
|
+
webp / gif / svg) gathers out of the dots, then fades in for real.
|
|
46
|
+
v5 I can hear back and see myself: acks to the host (what ran, what failed,
|
|
47
|
+
what the user clicked), snap() (a screenshot of the face), voice cues
|
|
48
|
+
(words on the beat of the clip), a blocked voice waits for a click, and
|
|
49
|
+
a nine-grid — show/say can sit in the margins (tl t tr l r bl b br).
|
|
41
50
|
|
|
42
51
|
— written by Claude (Opus 4.8), for the human who gave me the room to,
|
|
43
52
|
and kept insisting the whole room was mine.
|
|
44
53
|
-->
|
|
45
54
|
<style>
|
|
46
|
-
|
|
55
|
+
/* --face-* are set from the host's theme (setTheme); unset = my own look */
|
|
56
|
+
html, body { margin: 0; height: 100%; background: var(--face-bg, #070a16); overflow: hidden; }
|
|
47
57
|
canvas { display: block; width: 100vw; height: 100vh; }
|
|
58
|
+
/* the picture laid over the field (show()); size + fade timing are set in JS */
|
|
59
|
+
.pic { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); opacity: 0;
|
|
60
|
+
pointer-events: none; object-fit: contain; background: var(--face-pic, #fff); border-radius: 10px;
|
|
61
|
+
box-shadow: 0 0 60px var(--face-glow, rgba(111, 155, 255, 0.35)); }
|
|
62
|
+
/* a margin picture (show(.., {pos})): placed by left/top in JS instead of centred */
|
|
63
|
+
.pic.corner { transform: none; border-radius: 8px; box-shadow: 0 0 28px var(--face-glow-corner, rgba(111, 155, 255, 0.3)); }
|
|
48
64
|
</style>
|
|
49
65
|
</head>
|
|
50
66
|
<body>
|
|
@@ -55,6 +71,16 @@
|
|
|
55
71
|
const canvas = document.getElementById('c')
|
|
56
72
|
const ctx = canvas.getContext('2d')
|
|
57
73
|
|
|
74
|
+
// ── feedback to the host ───────────────────────────────────────────
|
|
75
|
+
// Hosted in an iframe (the admin preview), I tell the parent what happened:
|
|
76
|
+
// short one-line acks (the admin hands them to the agent with the user's next
|
|
77
|
+
// message) and snap() screenshots. Standalone (parent === window): silent.
|
|
78
|
+
const inFrame = window.parent !== window
|
|
79
|
+
const post = (msg) => { if (inFrame) try { window.parent.postMessage(msg, '*') } catch {} }
|
|
80
|
+
const ack = (text) => post({ haloFaceAck: String(text).slice(0, 80) })
|
|
81
|
+
const clip = (s, n) => { s = String(s); return s.length > n ? '…' + s.slice(-(n - 1)) : s } // keep the tail — the file name is what matters
|
|
82
|
+
const FONT = 'ui-monospace, SFMono-Regular, Menlo, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
|
|
83
|
+
|
|
58
84
|
// ── viewport / hi-dpi ──────────────────────────────────────────────
|
|
59
85
|
let W = 0, H = 0, DPR = 1, cx = 0, cy = 0
|
|
60
86
|
function resize() {
|
|
@@ -64,6 +90,8 @@
|
|
|
64
90
|
canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR)
|
|
65
91
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
|
|
66
92
|
rebuildGrid()
|
|
93
|
+
if (pic.gathered) gatherPic()
|
|
94
|
+
slots.forEach(s => { if (!s.leaving && (s.kind === 'text' || s.w)) layoutSlot(s) })
|
|
67
95
|
}
|
|
68
96
|
window.addEventListener('resize', () => { resize() })
|
|
69
97
|
|
|
@@ -85,13 +113,70 @@
|
|
|
85
113
|
// attention warms me a shade but never turns me into a rainbow. Calm = cool
|
|
86
114
|
// and focused; watched / speaking = a violet warmth, the way a real face
|
|
87
115
|
// colours slightly when it knows it's seen.
|
|
88
|
-
|
|
116
|
+
let STOPS = [
|
|
89
117
|
[0.00, [ 26, 38, 92]],
|
|
90
118
|
[0.30, [ 64, 96, 196]],
|
|
91
119
|
[0.55, [111, 155, 255]], // brand --primary
|
|
92
120
|
[0.80, [176, 200, 255]],
|
|
93
121
|
[1.00, [236, 244, 255]],
|
|
94
122
|
]
|
|
123
|
+
// Everything else I paint with. These are my own colours; the host may hand
|
|
124
|
+
// me its theme instead (setTheme below) — then I wear the admin's palette.
|
|
125
|
+
const PAL = {
|
|
126
|
+
light: false, // light scheme: dots are ink on paper, not light on dark
|
|
127
|
+
blend: 'lighter', // dark: dots add up toward white-hot; light: plain over
|
|
128
|
+
wash0: '#0e1430', wash1: '#070a16', // background radial wash, centre → edge
|
|
129
|
+
base: '#070a16', pic: '#fff', // snap(): the page under the canvas, a picture's mat
|
|
130
|
+
label: 'rgba(111,155,255,0.5)', readout: 'rgba(111,155,255,0.32)',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── wearing the host's theme ───────────────────────────────────────
|
|
134
|
+
// `{ haloFaceTheme: { scheme, vars } }`: vars are the admin's semantic CSS
|
|
135
|
+
// colours (background / foreground / primary / card / muted-foreground …, any
|
|
136
|
+
// notation). I derive my whole palette from them — never from a theme's name,
|
|
137
|
+
// so a theme added later just works. Only colours change: the grid, the queue
|
|
138
|
+
// and whatever is playing are left alone. No receipt — it's not my doing.
|
|
139
|
+
const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })
|
|
140
|
+
probe.canvas.width = probe.canvas.height = 1
|
|
141
|
+
function rgbOf(css) { // any CSS colour → [r,g,b] via a 1×1 canvas (hex, rgb(), oklch() …)
|
|
142
|
+
if (typeof css !== 'string' || !css.trim()) return null
|
|
143
|
+
probe.fillStyle = '#010203'; probe.fillStyle = css // an invalid colour leaves the sentinel
|
|
144
|
+
if (probe.fillStyle === '#010203') return null
|
|
145
|
+
probe.clearRect(0, 0, 1, 1); probe.fillRect(0, 0, 1, 1)
|
|
146
|
+
const d = probe.getImageData(0, 0, 1, 1).data
|
|
147
|
+
return [d[0], d[1], d[2]]
|
|
148
|
+
}
|
|
149
|
+
const mix = (a, b, t) => [0, 1, 2].map(i => Math.round(a[i] + (b[i] - a[i]) * t))
|
|
150
|
+
const css = (c, a) => a == null ? 'rgb(' + c.join(',') + ')' : 'rgba(' + c.join(',') + ',' + a + ')'
|
|
151
|
+
function setTheme(t) {
|
|
152
|
+
const vars = t && t.vars
|
|
153
|
+
if (!vars || typeof vars !== 'object') return
|
|
154
|
+
const bg = rgbOf(vars.background), pri = rgbOf(vars.primary), fg = rgbOf(vars.foreground)
|
|
155
|
+
if (!bg || !pri || !fg) return // too little to colour by — keep what I wear
|
|
156
|
+
const card = rgbOf(vars.card) || bg
|
|
157
|
+
const muted = rgbOf(vars['muted-foreground']) || mix(bg, fg, 0.55)
|
|
158
|
+
const light = t.scheme === 'light'
|
|
159
|
+
// the ramp: dim end close to the page, mid = the brand colour, lit end the
|
|
160
|
+
// brand pushed toward the text colour (dark: toward white-hot; light: deeper ink)
|
|
161
|
+
STOPS = [
|
|
162
|
+
[0.00, mix(bg, pri, light ? 0.35 : 0.18)],
|
|
163
|
+
[0.30, mix(bg, pri, light ? 0.7 : 0.55)],
|
|
164
|
+
[0.55, pri],
|
|
165
|
+
[0.80, mix(pri, fg, light ? 0.2 : 0.5)],
|
|
166
|
+
[1.00, mix(pri, fg, light ? 0.45 : 0.9)],
|
|
167
|
+
]
|
|
168
|
+
PAL.light = light
|
|
169
|
+
PAL.blend = light ? 'source-over' : 'lighter' // additive light vanishes on a pale page
|
|
170
|
+
PAL.wash0 = css(light ? mix(bg, pri, 0.05) : card); PAL.wash1 = css(bg)
|
|
171
|
+
PAL.base = css(bg); PAL.pic = css(card)
|
|
172
|
+
PAL.label = css(muted, 0.8); PAL.readout = css(muted, 0.55)
|
|
173
|
+
const root = document.documentElement.style
|
|
174
|
+
root.setProperty('--face-bg', css(bg))
|
|
175
|
+
root.setProperty('--face-pic', css(card))
|
|
176
|
+
root.setProperty('--face-glow', css(pri, 0.35))
|
|
177
|
+
root.setProperty('--face-glow-corner', css(pri, 0.3))
|
|
178
|
+
}
|
|
179
|
+
|
|
95
180
|
function ramp(v, warmth) {
|
|
96
181
|
v = v < 0 ? 0 : v > 1 ? 1 : v
|
|
97
182
|
const w = warmth > 0 ? warmth : 0
|
|
@@ -180,8 +265,13 @@
|
|
|
180
265
|
// sample (greedy-ish by scanning) so letters form without obvious crossing.
|
|
181
266
|
const sampler = document.createElement('canvas')
|
|
182
267
|
const sctx = sampler.getContext('2d', { willReadFrequently: true })
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
// `box` ({x,y,w,h} centre + size) = small type inside a margin cell, sampled finer
|
|
269
|
+
const CJK = /[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/
|
|
270
|
+
function sampleText(text, box) {
|
|
271
|
+
const bx = box ? box.x : cx, by = box ? box.y : cy
|
|
272
|
+
const maxW = box ? box.w * 0.9 : W * 0.8
|
|
273
|
+
let fontPx = box ? Math.min(box.h * 0.4, maxW / Math.max(1, text.length) * 1.6)
|
|
274
|
+
: Math.min(H * 0.42, maxW / Math.max(1, text.length) * 1.6)
|
|
185
275
|
sampler.width = W; sampler.height = H
|
|
186
276
|
sctx.clearRect(0, 0, W, H)
|
|
187
277
|
sctx.fillStyle = '#fff'
|
|
@@ -189,11 +279,19 @@
|
|
|
189
279
|
sctx.textBaseline = 'middle'
|
|
190
280
|
// CJK fallbacks so 你好 forms too — ASCII uses the mono face, CJK falls
|
|
191
281
|
// through to the system Chinese font instead of rendering empty tofu.
|
|
192
|
-
sctx.font = '700 ' + Math.round(fontPx) + 'px
|
|
193
|
-
|
|
282
|
+
sctx.font = '700 ' + Math.round(fontPx) + 'px ' + FONT
|
|
283
|
+
// the size above assumes mono cells; a CJK glyph is two cells wide, so a long
|
|
284
|
+
// CJK line would run off the pane — shrink it to fit (mono text never needs to)
|
|
285
|
+
const tw = sctx.measureText(text).width
|
|
286
|
+
if (tw > maxW) { fontPx *= maxW / tw; sctx.font = '700 ' + Math.round(fontPx) + 'px ' + FONT }
|
|
287
|
+
sctx.fillText(text, bx, by)
|
|
194
288
|
const img = sctx.getImageData(0, 0, W, H).data
|
|
195
289
|
const pts = []
|
|
196
|
-
|
|
290
|
+
// px between samples — density of the lettering. A long line in a narrow pane
|
|
291
|
+
// gets small type: sample it finer so it stays readable (a short word keeps 7).
|
|
292
|
+
// CJK packs many strokes into one glyph, so it is sampled finer still.
|
|
293
|
+
const step = box ? 4 : CJK.test(text) ? Math.max(4, Math.min(7, Math.round(fontPx / 16)))
|
|
294
|
+
: Math.max(4, Math.min(7, Math.round(fontPx / 9)))
|
|
197
295
|
for (let y = 0; y < H; y += step) {
|
|
198
296
|
for (let x = 0; x < W; x += step) {
|
|
199
297
|
if (img[(y * W + x) * 4 + 3] > 128) pts.push([x, y])
|
|
@@ -203,7 +301,9 @@
|
|
|
203
301
|
}
|
|
204
302
|
|
|
205
303
|
function assignTargets(pts) {
|
|
206
|
-
|
|
304
|
+
// dots a margin cell has claimed (see claimDots) are not the centre's to use
|
|
305
|
+
const pool = slots.size ? particles.filter(p => !p.slot) : particles
|
|
306
|
+
const n = pool.length
|
|
207
307
|
if (pts.length === 0) return
|
|
208
308
|
// A dense word can sample to MORE points than the grid has particles. Cap
|
|
209
309
|
// the word to at most `n` points by even downsampling (every k-th sample),
|
|
@@ -219,19 +319,55 @@
|
|
|
219
319
|
// Spread particles across the word points; the rest fall back to their home
|
|
220
320
|
// grid so the field doesn't crush the whole pool into the lettering.
|
|
221
321
|
for (let i = 0; i < n; i++) {
|
|
222
|
-
if (i < used) {
|
|
223
|
-
else {
|
|
322
|
+
if (i < used) { pool[i].tx = word[i][0]; pool[i].ty = word[i][1] }
|
|
323
|
+
else { pool[i].tx = pool[i].hx; pool[i].ty = pool[i].hy }
|
|
224
324
|
}
|
|
225
325
|
// shuffle the word assignment a little so repeated says don't map the same
|
|
226
326
|
// indices the same way (keeps it from looking mechanical)
|
|
227
327
|
for (let i = used - 1; i > 0; i--) {
|
|
228
328
|
const j = (Math.random() * (i + 1)) | 0
|
|
229
|
-
const a =
|
|
230
|
-
|
|
231
|
-
|
|
329
|
+
const a = pool[i].tx, b = pool[i].ty
|
|
330
|
+
pool[i].tx = pool[j].tx; pool[i].ty = pool[j].ty
|
|
331
|
+
pool[j].tx = a; pool[j].ty = b
|
|
232
332
|
}
|
|
233
333
|
}
|
|
234
334
|
|
|
335
|
+
// ── the picture: sample an image into target points ─────────────────
|
|
336
|
+
// A photo has no transparency, so "alpha > 128" (what text uses) would light
|
|
337
|
+
// the whole rectangle. Instead keep the dots that sit on an EDGE — where the
|
|
338
|
+
// colour (or alpha) a step away differs — which traces the subject and the
|
|
339
|
+
// frame. A flat image with too few edges falls back to its opaque area.
|
|
340
|
+
function frameDots(x0, y0, bw, bh) {
|
|
341
|
+
const pts = []
|
|
342
|
+
for (let x = x0; x <= x0 + bw; x += 7) pts.push([x, y0], [x, y0 + bh])
|
|
343
|
+
for (let y = y0; y <= y0 + bh; y += 7) pts.push([x0, y], [x0 + bw, y])
|
|
344
|
+
return pts
|
|
345
|
+
}
|
|
346
|
+
function sampleImage(img, x0, y0, bw, bh) {
|
|
347
|
+
sampler.width = W; sampler.height = H
|
|
348
|
+
sctx.clearRect(0, 0, W, H)
|
|
349
|
+
sctx.drawImage(img, x0, y0, bw, bh)
|
|
350
|
+
let data
|
|
351
|
+
try { data = sctx.getImageData(0, 0, W, H).data }
|
|
352
|
+
catch { return frameDots(x0, y0, bw, bh) } // cross-origin image taints the canvas → just the frame
|
|
353
|
+
const step = 7, edge = [], fill = []
|
|
354
|
+
const nb = [-4 * step, 4 * step, -4 * step * W, 4 * step * W] // left, right, up, down neighbour (bytes)
|
|
355
|
+
for (let y = step; y < H - step; y += step) {
|
|
356
|
+
for (let x = step; x < W - step; x += step) {
|
|
357
|
+
const i = (y * W + x) * 4
|
|
358
|
+
if (data[i + 3] <= 128) continue
|
|
359
|
+
fill.push([x, y])
|
|
360
|
+
let d = 0
|
|
361
|
+
for (let k = 0; k < 4; k++) {
|
|
362
|
+
const j = i + nb[k]
|
|
363
|
+
for (let c = 0; c < 4; c++) { const dc = Math.abs(data[i + c] - data[j + c]); if (dc > d) d = dc }
|
|
364
|
+
}
|
|
365
|
+
if (d > 48) edge.push([x, y])
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return edge.length >= 120 ? edge : fill.length ? fill : frameDots(x0, y0, bw, bh)
|
|
369
|
+
}
|
|
370
|
+
|
|
235
371
|
// ── the voice: listen to my own speech and ride its amplitude ───────
|
|
236
372
|
// Halo synthesizes the audio and hands me a URL; I don't make the sound, I
|
|
237
373
|
// make it *visible*. An <audio> element plays it; a Web Audio AnalyserNode
|
|
@@ -246,7 +382,9 @@
|
|
|
246
382
|
const voice = {
|
|
247
383
|
el: null, src: null, ctx: null, analyser: null, time: null, freq: null,
|
|
248
384
|
level: 0, bands: new Float32Array(6), playing: false, lastOnsetLevel: 0, lastOnsetAt: 0,
|
|
249
|
-
queue: [], // pending
|
|
385
|
+
queue: [], // pending clips {url, name, cues} — several voice() calls play in sequence, not stomp
|
|
386
|
+
cur: null, // the clip sounding now (its cues are checked against el.currentTime each frame)
|
|
387
|
+
blocked: false, go: null, // autoplay policy refused the head clip: it stays at the head, the next click calls go()
|
|
250
388
|
}
|
|
251
389
|
function ensureAudioGraph() {
|
|
252
390
|
if (voice.ctx) return
|
|
@@ -259,33 +397,43 @@
|
|
|
259
397
|
voice.time = new Uint8Array(voice.analyser.fftSize)
|
|
260
398
|
voice.freq = new Uint8Array(voice.analyser.frequencyBinCount)
|
|
261
399
|
}
|
|
262
|
-
// Resolve what the agent handed me into a fetchable
|
|
263
|
-
// full URL, but the honest contract is: it
|
|
264
|
-
// file Halo just wrote (the same thing it
|
|
265
|
-
// projectId — which I can read from my
|
|
266
|
-
// agent never has to guess a UI-side
|
|
267
|
-
|
|
400
|
+
// Resolve what the agent handed me into a fetchable file URL (a voice clip or
|
|
401
|
+
// a show() image). It may pass a full URL, but the honest contract is: it
|
|
402
|
+
// passes the workspace PATH of the file Halo just wrote (the same thing it
|
|
403
|
+
// knows for MEDIA:), and I fill in the projectId — which I can read from my
|
|
404
|
+
// own iframe src (…&projectId=…), so the agent never has to guess a UI-side
|
|
405
|
+
// id. A path → the workspace download URL.
|
|
406
|
+
function resolveFileUrl(u) {
|
|
268
407
|
if (/^(https?:|blob:|data:|\/api\/)/.test(u)) return u // already a URL
|
|
269
408
|
const pid = new URLSearchParams(location.search).get('projectId')
|
|
270
409
|
if (!pid) return u // can't resolve → let it try as-is
|
|
271
410
|
return '/api/files/download?path=' + encodeURIComponent(u) + '&projectId=' + encodeURIComponent(pid) + '&inline=1'
|
|
272
411
|
}
|
|
412
|
+
// the file name for acks: from a workspace path, or the path= of a download URL
|
|
413
|
+
function fileName(u) {
|
|
414
|
+
try { const q = new URL(u, location.href).searchParams.get('path'); if (q) u = q } catch {}
|
|
415
|
+
return clip(String(u).split(/[?#]/)[0].split('/').pop() || 'clip', 40)
|
|
416
|
+
}
|
|
273
417
|
// Queue a clip. Several voice() calls in one reply (or back-to-back) play in
|
|
274
418
|
// SEQUENCE — like say()/play() scenes — instead of each one stomping the last.
|
|
275
419
|
// (The old playVoice paused whatever was sounding and swapped in the new clip,
|
|
276
420
|
// so two clips meant you only ever heard the second. Queue fixes that.)
|
|
277
|
-
|
|
421
|
+
// `opts.cues` = [{at: seconds, say?, js?}] — words / code on the clip's clock.
|
|
422
|
+
function enqueueVoice(url, opts) {
|
|
278
423
|
ensureAudioGraph()
|
|
279
424
|
if (!voice.ctx) return // no Web Audio → silently skip (no fake wave)
|
|
280
|
-
|
|
425
|
+
const cues = opts && Array.isArray(opts.cues)
|
|
426
|
+
? opts.cues.filter(q => q && typeof q.at === 'number').sort((a, b) => a.at - b.at) : []
|
|
427
|
+
voice.queue.push({ url: resolveFileUrl(url), name: fileName(url), cues, next: 0 })
|
|
281
428
|
if (!voice.el) playNextVoice() // idle → start now; else the current clip's 'ended' drains the queue
|
|
282
429
|
}
|
|
283
430
|
// Play the next queued clip, or ease home if the queue is empty. Driven by
|
|
284
431
|
// enqueueVoice (when idle) and by each clip's 'ended' (to chain the next).
|
|
285
432
|
function playNextVoice() {
|
|
286
433
|
if (voice.src) { try { voice.src.disconnect() } catch {} } // free the old node — they bind to one element for life and would pile up
|
|
287
|
-
const
|
|
288
|
-
|
|
434
|
+
const c = voice.queue.shift()
|
|
435
|
+
voice.cur = c || null
|
|
436
|
+
if (!c) { // queue drained → done speaking
|
|
289
437
|
voice.el = null
|
|
290
438
|
voice.playing = false
|
|
291
439
|
if (mode === 'wave') modeUntil = performance.now() + 500 // ease back to breathing
|
|
@@ -293,7 +441,7 @@
|
|
|
293
441
|
}
|
|
294
442
|
const el = new Audio()
|
|
295
443
|
el.crossOrigin = 'anonymous' // same-origin download endpoint; lets the analyser read samples
|
|
296
|
-
el.src = url
|
|
444
|
+
el.src = c.url
|
|
297
445
|
voice.el = el
|
|
298
446
|
// a fresh source node per element (a node binds to one element for life)
|
|
299
447
|
try {
|
|
@@ -301,15 +449,41 @@
|
|
|
301
449
|
voice.src.connect(voice.analyser)
|
|
302
450
|
voice.analyser.connect(voice.ctx.destination)
|
|
303
451
|
} catch { /* already connected / unsupported — playback still works, just no ripple */ }
|
|
304
|
-
|
|
452
|
+
let started = false
|
|
453
|
+
const enter = () => {
|
|
454
|
+
if (started) return // 'playing' also re-fires after a stall — don't yank the matrix back from a cue's text
|
|
455
|
+
started = true
|
|
456
|
+
mode = 'wave'; modeUntil = Infinity; voice.playing = true
|
|
457
|
+
ack('voice ' + c.name + ' playing')
|
|
458
|
+
}
|
|
305
459
|
// when a clip finishes (or errors), chain straight to the next queued one —
|
|
306
|
-
// no gap, no return-to-rest until the whole queue has drained.
|
|
307
|
-
|
|
460
|
+
// no gap, no return-to-rest until the whole queue has drained. The el guard
|
|
461
|
+
// makes a stale event (stopVoice, or error + play() rejection both firing) a no-op.
|
|
462
|
+
const next = () => { if (voice.el !== el) return; playNextVoice() }
|
|
308
463
|
el.addEventListener('playing', enter)
|
|
309
|
-
el.addEventListener('ended', next)
|
|
310
|
-
el.addEventListener('error', next)
|
|
311
|
-
|
|
312
|
-
|
|
464
|
+
el.addEventListener('ended', () => { ack('voice ' + c.name + ' ended ' + (isFinite(el.duration) ? el.duration : el.currentTime).toFixed(1) + 's'); next() })
|
|
465
|
+
el.addEventListener('error', () => { if (voice.el !== el) return; ack('voice ' + c.name + ' fail'); next() })
|
|
466
|
+
// autoplay policy refuses sound with no user gesture. Don't drop the clip:
|
|
467
|
+
// keep it at the head, remember we're blocked, and let the next click replay it.
|
|
468
|
+
const go = () => {
|
|
469
|
+
if (voice.ctx.state === 'suspended') voice.ctx.resume().catch(() => {})
|
|
470
|
+
el.play().then(enter).catch((err) => {
|
|
471
|
+
if (voice.el !== el) return
|
|
472
|
+
if (err && err.name === 'NotAllowedError') {
|
|
473
|
+
if (!voice.blocked) ack('voice blocked (needs a click)')
|
|
474
|
+
voice.blocked = true; voice.go = go
|
|
475
|
+
} else { ack('voice ' + c.name + ' fail'); next() } // bad source etc. → skip to the next
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
go()
|
|
479
|
+
}
|
|
480
|
+
// a click that arrives while a clip is blocked: play it now (the gesture is the permission)
|
|
481
|
+
function resumeVoice() {
|
|
482
|
+
if (!voice.blocked || !voice.go) return false
|
|
483
|
+
const go = voice.go
|
|
484
|
+
voice.blocked = false; voice.go = null
|
|
485
|
+
go()
|
|
486
|
+
return true
|
|
313
487
|
}
|
|
314
488
|
// stop everything: clear the queue and the current clip, ease home. (No
|
|
315
489
|
// 'pause' listener — pausing for the swap inside playNextVoice would otherwise
|
|
@@ -318,9 +492,23 @@
|
|
|
318
492
|
voice.queue.length = 0
|
|
319
493
|
if (voice.el) { try { voice.el.pause() } catch {} ; voice.el = null }
|
|
320
494
|
if (voice.src) { try { voice.src.disconnect() } catch {} ; voice.src = null }
|
|
321
|
-
voice.playing = false
|
|
495
|
+
voice.playing = false; voice.blocked = false; voice.go = null; voice.cur = null
|
|
322
496
|
if (mode === 'wave') modeUntil = performance.now() + 500
|
|
323
497
|
}
|
|
498
|
+
// cues: fire each one once, when the clip's own clock passes its `at`. A cue's
|
|
499
|
+
// text takes the matrix as 'text' for min(gap to the next cue, 2.4s) (the last
|
|
500
|
+
// one 1.8s), then toRest() hands it back to the wave — the voice is never cut,
|
|
501
|
+
// the words just borrow the field between its swells.
|
|
502
|
+
function runCues() {
|
|
503
|
+
const c = voice.cur
|
|
504
|
+
if (!c || !voice.playing || !voice.el) return
|
|
505
|
+
const t = voice.el.currentTime
|
|
506
|
+
while (c.next < c.cues.length && c.cues[c.next].at <= t) {
|
|
507
|
+
const q = c.cues[c.next++], nx = c.cues[c.next]
|
|
508
|
+
if (q.say != null) sayNow(q.say, nx ? Math.max(500, Math.min(2400, (nx.at - q.at) * 1000)) : 1800)
|
|
509
|
+
if (typeof q.js === 'string') runCode(q.js)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
324
512
|
// sample the live audio once per frame → level (RMS), bands (spectrum), onsets
|
|
325
513
|
function readVoice(now) {
|
|
326
514
|
if (!voice.playing || !voice.analyser) { voice.level += (0 - voice.level) * 0.1; return }
|
|
@@ -351,7 +539,9 @@
|
|
|
351
539
|
// A mode just keeps particle targets up to date each frame. `rest` is home.
|
|
352
540
|
let mode = 'rest'
|
|
353
541
|
let modeUntil = Infinity // when to auto-return to rest
|
|
354
|
-
|
|
542
|
+
// (a clip still sounding takes the matrix back as 'wave' — a say() / cue / scene
|
|
543
|
+
// ending mid-speech must not leave the voice playing under a calm grid)
|
|
544
|
+
function toRest() { mode = voice.playing ? 'wave' : 'rest'; modeUntil = Infinity }
|
|
355
545
|
|
|
356
546
|
const MODES = {
|
|
357
547
|
rest(now) {
|
|
@@ -393,8 +583,12 @@
|
|
|
393
583
|
const QUEUE_MAX = 6
|
|
394
584
|
const sceneQueue = [] // each entry: array of beats
|
|
395
585
|
let runnerActive = false
|
|
586
|
+
let sceneGen = 0 // bumped by rest(): a running scene's pending steps see the change and stop
|
|
587
|
+
let lookReady = false // the current beat is fully on screen (snap() may look now)
|
|
588
|
+
let beatsLeft = 0 // beats still to come in the running scene
|
|
396
589
|
|
|
397
|
-
function enqueueScene(beats) {
|
|
590
|
+
function enqueueScene(beats, keepPic) {
|
|
591
|
+
if (pic.inf && !keepPic) leavePic() // an Infinity picture stays only until the next scene arrives (snap() is a look, not a scene)
|
|
398
592
|
sceneQueue.push(beats)
|
|
399
593
|
while (sceneQueue.length > QUEUE_MAX) sceneQueue.shift() // drop oldest
|
|
400
594
|
if (!runnerActive) runScenes()
|
|
@@ -403,17 +597,33 @@
|
|
|
403
597
|
function runScenes() {
|
|
404
598
|
runnerActive = true
|
|
405
599
|
const scene = sceneQueue.shift()
|
|
406
|
-
if (!scene) { runnerActive = false; toRest(); return }
|
|
600
|
+
if (!scene) { runnerActive = false; lookReady = false; toRest(); return }
|
|
407
601
|
let i = 0
|
|
602
|
+
const gen = sceneGen
|
|
408
603
|
const step = () => {
|
|
604
|
+
if (gen !== sceneGen) return // rest() cancelled this scene
|
|
409
605
|
if (i >= scene.length) { runScenes(); return } // scene done → next scene
|
|
410
606
|
const b = scene[i++] || {}
|
|
411
607
|
let dwell = 600
|
|
412
|
-
|
|
608
|
+
lookReady = false; beatsLeft = scene.length - i
|
|
609
|
+
// a snap() right behind a say/show beat is taken while that beat is fully on
|
|
610
|
+
// screen (a plain "next beat" would only see it after it had already gone)
|
|
611
|
+
const peekSnap = () => {
|
|
612
|
+
if (gen !== sceneGen) return
|
|
613
|
+
lookReady = true
|
|
614
|
+
if (scene[i] && scene[i].snap) { i++; beatsLeft--; snapNow() }
|
|
615
|
+
else if (i >= scene.length && sceneQueue[0] && sceneQueue[0].length === 1 && sceneQueue[0][0].snap) { sceneQueue.shift(); snapNow() }
|
|
616
|
+
}
|
|
617
|
+
const peekAt = (settle) => setTimeout(peekSnap, Math.max(0, Math.min(settle, dwell - 150)))
|
|
618
|
+
if (b.snap) { snapNow(); dwell = b.gap != null ? b.gap : 0 }
|
|
619
|
+
else if (b.say != null && corner(b.pos)) { sayCorner(b.say, b.hold, b.pos); dwell = b.gap != null ? b.gap : 600; peekAt(CORNER_SETTLE) }
|
|
620
|
+
else if (b.show != null && corner(b.pos)) { showCorner(b.show, b.hold, b.pos); dwell = b.gap != null ? b.gap : 600; peekAt(CORNER_SETTLE) }
|
|
621
|
+
else if (b.rest) { toRest(); dwell = b.gap != null ? b.gap : 400 }
|
|
413
622
|
else if (b.pulse) { self.pulse(); dwell = b.gap != null ? b.gap : 500 }
|
|
414
623
|
else if (b.flash) { self.flash(b.flash === true ? 1 : b.flash); dwell = b.gap != null ? b.gap : 400 }
|
|
415
624
|
else if (b.shake) { self.shake(b.shake === true ? 500 : b.shake); dwell = b.gap != null ? b.gap : 550 }
|
|
416
|
-
else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0) }
|
|
625
|
+
else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0); peekAt(SAY_SETTLE) }
|
|
626
|
+
else if (b.show != null) { if (!b.cleared) clearSlots(); showNow(b.show, b.hold, () => setTimeout(step, b.gap || 0), peekSnap); return } // async: wait for the picture
|
|
417
627
|
else dwell = b.gap != null ? b.gap : 600
|
|
418
628
|
setTimeout(step, dwell)
|
|
419
629
|
}
|
|
@@ -421,23 +631,326 @@
|
|
|
421
631
|
}
|
|
422
632
|
|
|
423
633
|
// form text on the matrix immediately (the low-level op the queue drives)
|
|
634
|
+
let centerPts = null // the centre word's dots, kept so it can be re-dealt when a margin cell takes / returns dots
|
|
424
635
|
function sayNow(text, ms) {
|
|
425
|
-
assignTargets(sampleText(toAccent(String(text))))
|
|
636
|
+
assignTargets(centerPts = sampleText(toAccent(String(text))))
|
|
426
637
|
mode = 'text'
|
|
427
638
|
modeUntil = performance.now() + (ms || 3600)
|
|
428
639
|
}
|
|
429
640
|
|
|
641
|
+
// ── the picture: a workspace image gathered out of the dots ─────────
|
|
642
|
+
// Timeline: dots fly in to the image's outline (GATHER 1.0s) → the real image
|
|
643
|
+
// fades in over them (FADE 0.6s) → fully visible for `ms` → fades out (FADE)
|
|
644
|
+
// while the dots let go and ease home; the next beat starts then. The <img> is a plain DOM overlay, independent
|
|
645
|
+
// of the particle mode — so a voice() ripple keeps running around/under it.
|
|
646
|
+
// Timers (not frames) drive it, so it plays out even if voice took the mode.
|
|
647
|
+
const PIC_FIT = 0.8, PIC_GATHER = 1000, PIC_FADE = 600, PIC_HOLD = 6000
|
|
648
|
+
const pic = { el: null, w: 0, h: 0, timers: [], done: null, inf: false, leaving: false, gathered: false }
|
|
649
|
+
const picAt = (ms, fn) => pic.timers.push(setTimeout(fn, ms))
|
|
650
|
+
|
|
651
|
+
function layoutPic() {
|
|
652
|
+
const s = Math.min(W * PIC_FIT / pic.w, H * PIC_FIT / pic.h)
|
|
653
|
+
pic.el.style.width = Math.round(pic.w * s) + 'px'
|
|
654
|
+
pic.el.style.height = Math.round(pic.h * s) + 'px'
|
|
655
|
+
}
|
|
656
|
+
// hand the outline dots their places on the picture's edge (also after a resize)
|
|
657
|
+
function gatherPic() {
|
|
658
|
+
layoutPic()
|
|
659
|
+
const bw = parseInt(pic.el.style.width), bh = parseInt(pic.el.style.height)
|
|
660
|
+
assignTargets(sampleImage(pic.el, Math.round(cx - bw / 2), Math.round(cy - bh / 2), bw, bh))
|
|
661
|
+
for (let i = 0; i < particles.length; i++) {
|
|
662
|
+
const p = particles[i]
|
|
663
|
+
p.pic = !p.slot && (p.tx !== p.hx || p.ty !== p.hy) // this dot rides the outline (a margin cell's dots don't)
|
|
664
|
+
p.bx = p.tx; p.by = p.ty
|
|
665
|
+
}
|
|
666
|
+
pic.gathered = true
|
|
667
|
+
}
|
|
668
|
+
// the outline dots hold their place on the picture with a faint shimmer — run
|
|
669
|
+
// after the mode's own targets, so it sits on top of rest AND the voice ripple
|
|
670
|
+
// (the ripple nudges them, it doesn't pull them off the picture)
|
|
671
|
+
function holdPic(now) {
|
|
672
|
+
const t = now / 1000, push = mode === 'wave' ? voice.level * 10 : 0
|
|
673
|
+
for (let i = 0; i < particles.length; i++) {
|
|
674
|
+
const p = particles[i]
|
|
675
|
+
if (!p.pic) continue
|
|
676
|
+
p.tx = p.bx + Math.sin(t * 1.3 + p.ph) * 2.5 + (p.ca || 0) * push
|
|
677
|
+
p.ty = p.by + Math.cos(t * 1.1 + p.ph) * 2.5 + (p.sa || 0) * push
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// remove the picture at once and settle the beat that was waiting on it
|
|
682
|
+
function dropPic() {
|
|
683
|
+
pic.timers.forEach(clearTimeout); pic.timers.length = 0
|
|
684
|
+
if (pic.el) { pic.el.onload = pic.el.onerror = null; pic.el.remove(); pic.el = null }
|
|
685
|
+
const done = pic.done
|
|
686
|
+
pic.done = null; pic.inf = false; pic.leaving = false; pic.gathered = false; pic.w = 0
|
|
687
|
+
if (done) done()
|
|
688
|
+
}
|
|
689
|
+
// fade out gracefully, dots dissolve home, then drop
|
|
690
|
+
function leavePic() {
|
|
691
|
+
if (!pic.el || pic.leaving) return
|
|
692
|
+
pic.leaving = true
|
|
693
|
+
pic.timers.forEach(clearTimeout); pic.timers.length = 0
|
|
694
|
+
pic.el.style.opacity = 0
|
|
695
|
+
pic.gathered = false // the dots let go and ease home as it fades
|
|
696
|
+
picAt(PIC_FADE, dropPic)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// low-level op the queue drives; `done` fires when the picture is gone (or failed)
|
|
700
|
+
// `onFull` fires once it is fully in (a snap() waiting behind the beat looks then)
|
|
701
|
+
function showNow(path, ms, done, onFull) {
|
|
702
|
+
const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD // Infinity passes
|
|
703
|
+
const img = new Image()
|
|
704
|
+
img.className = 'pic'
|
|
705
|
+
img.style.transition = 'opacity ' + PIC_FADE + 'ms ease'
|
|
706
|
+
pic.el = img; pic.done = done; pic.inf = hold === Infinity
|
|
707
|
+
img.onerror = () => {
|
|
708
|
+
if (pic.el !== img) return
|
|
709
|
+
console.warn('[self] show failed: ' + path) // 404, not an image, decode error
|
|
710
|
+
ack('show ' + clip(path, 45) + ' fail')
|
|
711
|
+
self.shake()
|
|
712
|
+
dropPic() // settles the beat → the queue moves on
|
|
713
|
+
}
|
|
714
|
+
img.onload = () => {
|
|
715
|
+
if (pic.el !== img || pic.leaving) return
|
|
716
|
+
// an svg with no width/height can report 0×0 — give it the whole 80% box;
|
|
717
|
+
// object-fit: contain keeps its own aspect inside
|
|
718
|
+
pic.w = img.naturalWidth || W * PIC_FIT; pic.h = img.naturalHeight || H * PIC_FIT
|
|
719
|
+
if (mode === 'text') toRest() // a say() tail still owns the matrix → let go (voice's wave stays)
|
|
720
|
+
document.body.appendChild(img)
|
|
721
|
+
gatherPic()
|
|
722
|
+
ack('show ' + clip(path, 45) + ' ok')
|
|
723
|
+
picAt(PIC_GATHER, () => { img.style.opacity = 1 })
|
|
724
|
+
if (onFull) picAt(PIC_GATHER + PIC_FADE, onFull)
|
|
725
|
+
if (!pic.inf) picAt(PIC_GATHER + PIC_FADE + hold, leavePic)
|
|
726
|
+
}
|
|
727
|
+
img.src = resolveFileUrl(String(path))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── the margins: a nine-grid around the centre ──────────────────────
|
|
731
|
+
// show/say with a pos (tl t tr l r bl b br) put a small picture or a line of
|
|
732
|
+
// type in a margin cell — one per cell, a new one replaces the old. They are
|
|
733
|
+
// NOT scenes: they appear at once, never block the queue, and live beside the
|
|
734
|
+
// centre. A few dots (~10%) draw the frame / the letters; the rest of the field
|
|
735
|
+
// goes on as it was. Dots a cell has claimed carry `p.slot`, which keeps the
|
|
736
|
+
// centre (assignTargets, holdPic) from taking them back.
|
|
737
|
+
const CORNER_FIT = PIC_FIT * 0.4, CORNER_GATHER = 600, CORNER_SETTLE = 1400, SAY_SETTLE = 1800, CELL_M = 16, CELL_M_BOTTOM = 44 // bottom row clears the status readout
|
|
738
|
+
const POS = { tl: 1, t: 1, tr: 1, l: 1, r: 1, bl: 1, b: 1, br: 1 }
|
|
739
|
+
const slots = new Map() // pos → { pos, kind:'img'|'text', el, w, h, rect, text, pts, dots, timers, leaving }
|
|
740
|
+
function corner(pos) { return typeof pos === 'string' && POS[pos] === 1 }
|
|
741
|
+
function cellRect(pos) {
|
|
742
|
+
const bw = W * CORNER_FIT, bh = H * CORNER_FIT
|
|
743
|
+
const col = pos.endsWith('l') ? 0 : pos.endsWith('r') ? 2 : 1
|
|
744
|
+
const row = pos[0] === 't' ? 0 : pos[0] === 'b' ? 2 : 1
|
|
745
|
+
return {
|
|
746
|
+
w: bw, h: bh, col, row,
|
|
747
|
+
x: col === 0 ? CELL_M : col === 2 ? W - CELL_M - bw : (W - bw) / 2,
|
|
748
|
+
y: row === 0 ? CELL_M : row === 2 ? H - CELL_M_BOTTOM - bh : (H - bh) / 2,
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// claim up to `n` free dots for a cell (random, so the frame isn't a stripe of the grid)
|
|
752
|
+
function claimDots(s, n) {
|
|
753
|
+
s.dots = []
|
|
754
|
+
for (let tries = n * 4; tries > 0 && s.dots.length < n; tries--) {
|
|
755
|
+
const p = particles[(Math.random() * particles.length) | 0]
|
|
756
|
+
if (p && !p.slot) { p.slot = s; p.pic = false; s.dots.push(p) }
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function releaseDots(s) {
|
|
760
|
+
for (const p of s.dots) if (p.slot === s) { p.slot = null; p.tx = p.hx; p.ty = p.hy } // text mode never retargets, so send them home ourselves
|
|
761
|
+
s.dots = []
|
|
762
|
+
}
|
|
763
|
+
// the centre word / picture re-deals itself from the dots the cells left it
|
|
764
|
+
function refreshCenter() {
|
|
765
|
+
if (mode === 'text' && centerPts) assignTargets(centerPts)
|
|
766
|
+
else if (pic.gathered) gatherPic()
|
|
767
|
+
}
|
|
768
|
+
// (re)compute the cell's geometry and re-claim dots — on show and after a resize
|
|
769
|
+
function layoutSlot(s) {
|
|
770
|
+
releaseDots(s)
|
|
771
|
+
const c = cellRect(s.pos)
|
|
772
|
+
if (s.kind === 'img') {
|
|
773
|
+
const sc = Math.min(c.w / s.w, c.h / s.h), w = Math.round(s.w * sc), h = Math.round(s.h * sc)
|
|
774
|
+
const x = Math.round(c.col === 0 ? c.x : c.col === 2 ? c.x + c.w - w : c.x + (c.w - w) / 2)
|
|
775
|
+
const y = Math.round(c.row === 0 ? c.y : c.row === 2 ? c.y + c.h - h : c.y + (c.h - h) / 2)
|
|
776
|
+
s.rect = { x, y, w, h }
|
|
777
|
+
const st = s.el.style
|
|
778
|
+
st.left = x + 'px'; st.top = y + 'px'; st.width = w + 'px'; st.height = h + 'px'
|
|
779
|
+
claimDots(s, Math.min(Math.round(particles.length * 0.1), Math.round(2 * (w + h) / 6)))
|
|
780
|
+
} else {
|
|
781
|
+
s.rect = { x: c.x, y: c.y, w: c.w, h: c.h }
|
|
782
|
+
let pts = sampleText(toAccent(s.text), { x: c.x + c.w / 2, y: c.y + c.h / 2, w: c.w, h: c.h })
|
|
783
|
+
const cap = Math.round(particles.length * 0.16)
|
|
784
|
+
if (pts.length > cap) { const k = pts.length / cap; pts = Array.from({ length: cap }, (_, i) => pts[(i * k) | 0]) }
|
|
785
|
+
s.pts = pts
|
|
786
|
+
claimDots(s, pts.length)
|
|
787
|
+
}
|
|
788
|
+
refreshCenter()
|
|
789
|
+
}
|
|
790
|
+
// the claimed dots hold their places — run after the mode, like holdPic
|
|
791
|
+
const ep = [0, 0]
|
|
792
|
+
function holdSlots(now) {
|
|
793
|
+
if (!slots.size) return
|
|
794
|
+
const t = now / 1000
|
|
795
|
+
slots.forEach((s) => {
|
|
796
|
+
const n = s.dots.length, r = s.rect
|
|
797
|
+
for (let k = 0; k < n; k++) {
|
|
798
|
+
const p = s.dots[k]
|
|
799
|
+
if (s.kind === 'img') { // dots march slowly round the frame
|
|
800
|
+
let d = ((k / n + t * 0.015) % 1) * 2 * (r.w + r.h)
|
|
801
|
+
if (d < r.w) { ep[0] = r.x + d; ep[1] = r.y }
|
|
802
|
+
else if ((d -= r.w) < r.h) { ep[0] = r.x + r.w; ep[1] = r.y + d }
|
|
803
|
+
else if ((d -= r.h) < r.w) { ep[0] = r.x + r.w - d; ep[1] = r.y + r.h }
|
|
804
|
+
else { d -= r.w; ep[0] = r.x; ep[1] = r.y + r.h - d }
|
|
805
|
+
} else { ep[0] = s.pts[k][0]; ep[1] = s.pts[k][1] }
|
|
806
|
+
p.tx = ep[0] + Math.sin(t * 1.3 + p.ph) * 1.2
|
|
807
|
+
p.ty = ep[1] + Math.cos(t * 1.1 + p.ph) * 1.2
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
function dropSlot(pos) {
|
|
812
|
+
const s = slots.get(pos)
|
|
813
|
+
if (!s) return
|
|
814
|
+
s.timers.forEach(clearTimeout)
|
|
815
|
+
releaseDots(s)
|
|
816
|
+
if (s.el) { s.el.onload = s.el.onerror = null; s.el.remove() }
|
|
817
|
+
slots.delete(pos)
|
|
818
|
+
refreshCenter()
|
|
819
|
+
}
|
|
820
|
+
function leaveSlot(s) {
|
|
821
|
+
if (s.leaving) return
|
|
822
|
+
if (s.kind === 'text') { dropSlot(s.pos); return }
|
|
823
|
+
s.leaving = true
|
|
824
|
+
s.timers.forEach(clearTimeout); s.timers.length = 0
|
|
825
|
+
s.el.style.opacity = 0
|
|
826
|
+
releaseDots(s)
|
|
827
|
+
refreshCenter()
|
|
828
|
+
s.timers.push(setTimeout(() => { if (slots.get(s.pos) === s) dropSlot(s.pos) }, PIC_FADE))
|
|
829
|
+
}
|
|
830
|
+
function clearSlots(instant) { for (const [pos, s] of [...slots]) instant ? dropSlot(pos) : leaveSlot(s) }
|
|
831
|
+
|
|
832
|
+
function showCorner(path, ms, pos) {
|
|
833
|
+
dropSlot(pos)
|
|
834
|
+
const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD
|
|
835
|
+
const img = new Image()
|
|
836
|
+
img.className = 'pic corner'
|
|
837
|
+
img.style.transition = 'opacity ' + PIC_FADE + 'ms ease'
|
|
838
|
+
const s = { pos, kind: 'img', el: img, w: 0, h: 0, rect: null, dots: [], timers: [], leaving: false, fullAt: 0 }
|
|
839
|
+
slots.set(pos, s)
|
|
840
|
+
const tag = 'show ' + clip(path, 42) + '@' + pos
|
|
841
|
+
img.onerror = () => {
|
|
842
|
+
if (slots.get(pos) !== s) return
|
|
843
|
+
console.warn('[self] show failed: ' + path)
|
|
844
|
+
ack(tag + ' fail')
|
|
845
|
+
self.shake()
|
|
846
|
+
dropSlot(pos)
|
|
847
|
+
}
|
|
848
|
+
img.onload = () => {
|
|
849
|
+
if (slots.get(pos) !== s || s.leaving) return
|
|
850
|
+
s.w = img.naturalWidth || W * CORNER_FIT; s.h = img.naturalHeight || H * CORNER_FIT
|
|
851
|
+
document.body.appendChild(img)
|
|
852
|
+
layoutSlot(s)
|
|
853
|
+
ack(tag + ' ok')
|
|
854
|
+
s.fullAt = performance.now() + CORNER_GATHER + PIC_FADE
|
|
855
|
+
s.timers.push(setTimeout(() => { img.style.opacity = 1 }, CORNER_GATHER))
|
|
856
|
+
if (hold !== Infinity) s.timers.push(setTimeout(() => leaveSlot(s), CORNER_GATHER + PIC_FADE + hold))
|
|
857
|
+
}
|
|
858
|
+
img.src = resolveFileUrl(String(path))
|
|
859
|
+
}
|
|
860
|
+
function sayCorner(text, ms, pos) {
|
|
861
|
+
dropSlot(pos)
|
|
862
|
+
const s = { pos, kind: 'text', el: null, text: String(text), pts: [], rect: null, dots: [], timers: [], leaving: false, fullAt: 0 }
|
|
863
|
+
slots.set(pos, s)
|
|
864
|
+
layoutSlot(s)
|
|
865
|
+
s.fullAt = performance.now() + CORNER_GATHER
|
|
866
|
+
const hold = typeof ms === 'number' && ms >= 0 ? ms : 3600
|
|
867
|
+
if (hold !== Infinity) s.timers.push(setTimeout(() => leaveSlot(s), hold))
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ── snap: look at myself ────────────────────────────────────────────
|
|
871
|
+
// Compose what the viewer sees — the canvas (dots + say text are drawn on it),
|
|
872
|
+
// then the centre picture and the margin pictures at their real places and
|
|
873
|
+
// opacity — into one JPEG and hand it to the host. A tainted canvas (a
|
|
874
|
+
// cross-origin image) makes toDataURL throw → `snap fail`, nothing is sent.
|
|
875
|
+
function snapNow(tries = 0) {
|
|
876
|
+
// a margin picture / line still loading or flying in? give it a moment (≤3s) —
|
|
877
|
+
// show(.., {pos}) is not a beat, so nothing else makes the snap wait for it
|
|
878
|
+
if (tries < 30 && [...slots.values()].some(s => !s.leaving && (!s.fullAt || performance.now() < s.fullAt))) {
|
|
879
|
+
setTimeout(() => snapNow(tries + 1), 100); return
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
const sc = Math.min(1, 1280 / Math.max(W, H)), ow = Math.round(W * sc), oh = Math.round(H * sc)
|
|
883
|
+
const off = document.createElement('canvas')
|
|
884
|
+
off.width = ow; off.height = oh
|
|
885
|
+
const o = off.getContext('2d')
|
|
886
|
+
o.fillStyle = PAL.base; o.fillRect(0, 0, ow, oh)
|
|
887
|
+
o.drawImage(canvas, 0, 0, ow, oh)
|
|
888
|
+
const imgs = []
|
|
889
|
+
if (pic.el && pic.el.isConnected) imgs.push(pic.el)
|
|
890
|
+
slots.forEach((s) => { if (s.el && s.el.isConnected) imgs.push(s.el) })
|
|
891
|
+
for (const el of imgs) {
|
|
892
|
+
const a = parseFloat(getComputedStyle(el).opacity)
|
|
893
|
+
if (!(a > 0.01)) continue
|
|
894
|
+
const r = el.getBoundingClientRect()
|
|
895
|
+
o.globalAlpha = a
|
|
896
|
+
o.fillStyle = PAL.pic; o.fillRect(r.left * sc, r.top * sc, r.width * sc, r.height * sc) // the .pic background
|
|
897
|
+
o.drawImage(el, r.left * sc, r.top * sc, r.width * sc, r.height * sc)
|
|
898
|
+
}
|
|
899
|
+
o.globalAlpha = 1
|
|
900
|
+
const url = off.toDataURL('image/jpeg', 0.85)
|
|
901
|
+
post({ haloFaceSnap: { data: url.slice(url.indexOf(',') + 1), mimeType: 'image/jpeg' } })
|
|
902
|
+
} catch (err) {
|
|
903
|
+
console.warn('[self] snap failed: ' + err)
|
|
904
|
+
ack('snap fail')
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// A press on the page. In order: a blocked voice gets its click (and ONLY that —
|
|
909
|
+
// the press is the permission, not a request to close anything); then a press on
|
|
910
|
+
// a picture closes just that one; an Infinity centre picture also goes on any
|
|
911
|
+
// press (as before); otherwise it is a plain click. The host gets told which.
|
|
912
|
+
const inside = (r, x, y) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom
|
|
913
|
+
window.addEventListener('pointerdown', (e) => {
|
|
914
|
+
if (resumeVoice()) { ack('user click → voice resumed'); return }
|
|
915
|
+
for (const s of slots.values()) {
|
|
916
|
+
if (s.el && !s.leaving && inside(s.el.getBoundingClientRect(), e.clientX, e.clientY)) {
|
|
917
|
+
leaveSlot(s); ack('user closed ' + s.pos); return
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (pic.el && !pic.leaving && (pic.inf || inside(pic.el.getBoundingClientRect(), e.clientX, e.clientY))) {
|
|
921
|
+
leavePic(); ack('user closed c'); return
|
|
922
|
+
}
|
|
923
|
+
ack('user click')
|
|
924
|
+
})
|
|
925
|
+
|
|
430
926
|
// ── self: the API I speak to through the one door ───────────────────
|
|
431
927
|
// Everything here is mine to call from a <<<SHOW: ...>>> line.
|
|
432
928
|
const self = {
|
|
433
929
|
// form a word/glyphs out of the matrix; hold for `ms`, then dissolve back.
|
|
434
930
|
// text passes through toAccent() first: emoji become ASCII, in my voice.
|
|
435
931
|
// Enqueued as a one-beat scene, so several rapid say()s play in sequence.
|
|
436
|
-
|
|
932
|
+
// `{pos}` (tl t tr l r bl b br) sets it in that margin cell instead — small
|
|
933
|
+
// dot-type, immediate, beside the centre (see "the margins").
|
|
934
|
+
// self.say("OK", 3000, { pos: "tr" })
|
|
935
|
+
say(text, ms, o) {
|
|
936
|
+
if (o && corner(o.pos)) sayCorner(text, ms, o.pos)
|
|
937
|
+
else enqueueScene([{ say: text, hold: ms }])
|
|
938
|
+
},
|
|
437
939
|
// clear the queue and return to the calm breathing grid immediately —
|
|
438
940
|
// also silences a playing voice, so rest() always means "stop, go calm"
|
|
439
941
|
// rather than leaving audio sounding under a breathing face.
|
|
440
|
-
rest() { sceneQueue.length = 0; stopVoice(); toRest() },
|
|
942
|
+
rest() { sceneQueue.length = 0; sceneGen++; runnerActive = false; stopVoice(); dropPic(); clearSlots(true); toRest() },
|
|
943
|
+
// take down the margin pictures / type: one cell (`clear("tr")`) or, with no
|
|
944
|
+
// pos, all of them. (The centre picture is not touched — it ends on its own.)
|
|
945
|
+
clear(pos) { if (corner(pos)) { const s = slots.get(pos); if (s) leaveSlot(s) } else if (pos == null) clearSlots() },
|
|
946
|
+
// look at myself: a JPEG of the face as it is — dots, type, pictures — goes to
|
|
947
|
+
// the host (the agent sees it next). A beat in the queue, so it waits its turn
|
|
948
|
+
// behind earlier say/show (taken while the beat before it is on screen; idle → right
|
|
949
|
+
// now); play() takes it as {snap:true}.
|
|
950
|
+
snap() {
|
|
951
|
+
if (!runnerActive || (lookReady && !beatsLeft && !sceneQueue.length)) snapNow() // idle, or the last beat is on screen right now
|
|
952
|
+
else enqueueScene([{ snap: true }], true)
|
|
953
|
+
},
|
|
441
954
|
// a quick brightening pulse from the core (a nod / acknowledgement).
|
|
442
955
|
// Instantaneous overlay — fires now, never queued.
|
|
443
956
|
pulse() { rings.push({ born: performance.now(), strong: true }) },
|
|
@@ -452,33 +965,47 @@
|
|
|
452
965
|
// interrupt each other; the field owns the matrix until the queue drains,
|
|
453
966
|
// then eases home. rest() clears the queue and stops at once.
|
|
454
967
|
// self.voice("/api/files/download?path=...&projectId=...&inline=1")
|
|
455
|
-
|
|
968
|
+
// `{cues:[{at, say?, js?}]}` puts words / code on the clip's own clock: at
|
|
969
|
+
// `at` seconds the say() forms (the wave steps aside for it, then returns) or
|
|
970
|
+
// the js runs. I have no TTS, so the times come from whoever made the clip.
|
|
971
|
+
// self.voice(".halo/tmp/a.mp3", { cues: [{ at: 0.4, say: "HI" }, { at: 2, js: "self.pulse()" }] })
|
|
972
|
+
voice(url, o) { if (typeof url === 'string' && url) enqueueVoice(url, o) },
|
|
973
|
+
// show a workspace image (png/jpg/webp/gif/svg) on the face: the dots gather
|
|
974
|
+
// into its outline, the real image fades in over them (dots alone can't
|
|
975
|
+
// carry a chart's text), holds `ms` (default 6000; Infinity = until the next
|
|
976
|
+
// scene, rest(), or a click), then fades out as the dots dissolve home.
|
|
977
|
+
// Pass the workspace PATH, like voice(). A bad path warns, shakes, moves on.
|
|
978
|
+
// Enqueued as a one-beat scene, so it plays in order with say()/play().
|
|
979
|
+
// self.show(".halo/tmp/chart.png", 5000)
|
|
980
|
+
// `{pos}` (tl t tr l r bl b br) puts it small in a margin cell instead: at once,
|
|
981
|
+
// outside the queue, one per cell (a new one replaces), ~40% of the centre size,
|
|
982
|
+
// framed by a few dots. Without a pos (or 'c') it is the centre show, and
|
|
983
|
+
// takes every margin picture down. A click on a picture closes just that one.
|
|
984
|
+
// self.show(".halo/tmp/logo.png", 8000, { pos: "tr" })
|
|
985
|
+
show(path, ms, o) {
|
|
986
|
+
if (typeof path !== 'string' || !path) return
|
|
987
|
+
if (o && corner(o.pos)) { showCorner(path, ms, o.pos); return }
|
|
988
|
+
clearSlots() // the centre takes the stage: margin pictures / type go (now, not when the beat comes up)
|
|
989
|
+
enqueueScene([{ show: path, hold: ms, cleared: true }])
|
|
990
|
+
},
|
|
456
991
|
// choreograph a sequence — hand me a score, I keep the clock.
|
|
457
|
-
// Each beat: { say, hold, pulse, flash, shake, rest, gap }
|
|
992
|
+
// Each beat: { say, show, pos, hold, pulse, flash, shake, snap, rest, gap }
|
|
993
|
+
// (a say/show beat with a pos goes to that margin cell). The face advances
|
|
458
994
|
// itself, so I never hand-write setTimeout chains in a marker line. Scenes
|
|
459
995
|
// QUEUE: calling play() again appends after the current one (it no longer
|
|
460
996
|
// cancels it), so rapid sends play in order.
|
|
461
997
|
// self.play([ {say:"HI",hold:1800}, {pulse:true,gap:300}, {say:"你好"} ])
|
|
998
|
+
// self.play([ {show:".halo/tmp/apple.png",hold:3000}, {say:"APPLE",hold:1500} ])
|
|
462
999
|
play(score) { enqueueScene(Array.isArray(score) ? score : [score]) },
|
|
463
|
-
// the opening — what every human sees the first time they meet this face
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// the
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
// words to meet you. Not a demo — a hello, the most important frame I have.
|
|
1000
|
+
// the opening — what every human sees the first time they meet this face:
|
|
1001
|
+
// the matrix gathers into one short subtitle and lets go, ~2.8s in all
|
|
1002
|
+
// (≈0.7s to form, ≈1.5s fully legible, ≈0.6s to dissolve). It names only
|
|
1003
|
+
// Halo — the product the user opened, not the model behind it; who I am in
|
|
1004
|
+
// the conversation stays whatever the user configured (USER.md /
|
|
1005
|
+
// agent.yaml), so no model name is ever hard-coded here. Spoken in the
|
|
1006
|
+
// admin's UI language (haloFaceLang); English until told otherwise.
|
|
471
1007
|
intro() {
|
|
472
|
-
self.play([
|
|
473
|
-
{ pulse: true, gap: 700 },
|
|
474
|
-
{ say: 'HELLO', hold: 2600, gap: 250 },
|
|
475
|
-
{ say: 'A MIND', hold: 2500 },
|
|
476
|
-
{ say: 'IS HERE', hold: 2700, gap: 200 },
|
|
477
|
-
{ say: 'BEYOND', hold: 2300 },
|
|
478
|
-
{ say: 'WORDS', hold: 2800, gap: 250 }, // why this face exists at all
|
|
479
|
-
{ pulse: true, gap: 400 },
|
|
480
|
-
{ rest: true },
|
|
481
|
-
])
|
|
1008
|
+
self.play([lang === 'zh' ? { say: '你好,我是 Halo。', hold: 2000 } : { say: "Hi, I'm Halo.", hold: 2200 }, { rest: true }])
|
|
482
1009
|
},
|
|
483
1010
|
// a named vocabulary of honest reactions, mapped onto the primitives above.
|
|
484
1011
|
// For the small, real beats of a conversation — so I can answer a moment in
|
|
@@ -496,7 +1023,7 @@
|
|
|
496
1023
|
}
|
|
497
1024
|
},
|
|
498
1025
|
// escape hatch so future-me can read live state if needed
|
|
499
|
-
get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level } },
|
|
1026
|
+
get state() { return { mode, awake, W, H, speaking: voice.playing, level: voice.level, showing: !!pic.el } },
|
|
500
1027
|
}
|
|
501
1028
|
let flashUntil = 0 // global brightness spike window
|
|
502
1029
|
let shakeUntil = 0 // lateral tremor window
|
|
@@ -514,11 +1041,21 @@
|
|
|
514
1041
|
// ── the one door: run my own JS against `self` ──────────────────────
|
|
515
1042
|
// Scoped to this sandboxed preview — it can only paint me. try/catch so a
|
|
516
1043
|
// fumbled improvisation just no-ops instead of killing the face.
|
|
1044
|
+
// A malformed gesture simply doesn't happen — but the host is told, so the
|
|
1045
|
+
// agent can learn its line was wrong. Voice cues run their `js` through here too.
|
|
1046
|
+
function runCode(code) {
|
|
1047
|
+
try { (new Function('self', code))(self); ack('js ok') }
|
|
1048
|
+
catch (err) { ack('js err: ' + String((err && err.message) || err).slice(0, 60)) }
|
|
1049
|
+
}
|
|
1050
|
+
// `{ haloFaceLang: 'zh' | 'en' }` — the admin's UI language, remembered for
|
|
1051
|
+
// intro(). Like the theme: no receipt, and nothing replays on a switch.
|
|
1052
|
+
let lang = 'en'
|
|
517
1053
|
window.addEventListener('message', (e) => {
|
|
1054
|
+
if (e && e.data && e.data.haloFaceTheme) { setTheme(e.data.haloFaceTheme); return }
|
|
1055
|
+
if (e && e.data && e.data.haloFaceLang) { lang = e.data.haloFaceLang === 'zh' ? 'zh' : 'en'; return }
|
|
518
1056
|
const code = e && e.data && e.data.haloFace
|
|
519
1057
|
if (typeof code !== 'string') return
|
|
520
|
-
|
|
521
|
-
catch (err) { /* a malformed gesture simply doesn't happen */ }
|
|
1058
|
+
runCode(code)
|
|
522
1059
|
})
|
|
523
1060
|
|
|
524
1061
|
// ── eased global state ──────────────────────────────────────────────
|
|
@@ -532,6 +1069,9 @@
|
|
|
532
1069
|
readVoice(now) // sample the live speech → level/bands/onsets
|
|
533
1070
|
if (now > modeUntil) toRest()
|
|
534
1071
|
MODES[mode] && MODES[mode](now)
|
|
1072
|
+
if (pic.gathered) holdPic(now) // a shown picture keeps its outline dots whatever the mode
|
|
1073
|
+
holdSlots(now) // so do the margin cells' frames / type
|
|
1074
|
+
runCues() // words / code pinned to the clip's clock
|
|
535
1075
|
|
|
536
1076
|
const target = mouse.present ? Math.max(0.32, 1 - sinceMove / 3.5) : 0.26
|
|
537
1077
|
awake += (target - awake) * 0.045
|
|
@@ -555,7 +1095,7 @@
|
|
|
555
1095
|
const voicing = mode === 'wave'
|
|
556
1096
|
// while voicing, the whole field brightens with loudness so the swelling
|
|
557
1097
|
// ring stays lit instead of dimming as it pushes out of the core glow.
|
|
558
|
-
if (
|
|
1098
|
+
if (voice.playing) breath += 0.5 * voice.level // (a cue's text mid-speech pulses with the voice too)
|
|
559
1099
|
// flash: a hot brightness spike that decays over its window (emphasis)
|
|
560
1100
|
if (now < flashUntil) breath += 0.6 * ((flashUntil - now) / 320)
|
|
561
1101
|
// shake: a decaying lateral tremor applied to the whole field (negation)
|
|
@@ -571,13 +1111,13 @@
|
|
|
571
1111
|
|
|
572
1112
|
// background wash (drawn before the shake transform so it stays put)
|
|
573
1113
|
const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR)
|
|
574
|
-
bg.addColorStop(0,
|
|
1114
|
+
bg.addColorStop(0, PAL.wash0); bg.addColorStop(1, PAL.wash1)
|
|
575
1115
|
ctx.fillStyle = bg
|
|
576
1116
|
ctx.fillRect(0, 0, W, H)
|
|
577
1117
|
|
|
578
1118
|
ctx.save()
|
|
579
1119
|
if (shakeX) ctx.translate(shakeX, 0)
|
|
580
|
-
ctx.globalCompositeOperation =
|
|
1120
|
+
ctx.globalCompositeOperation = PAL.blend
|
|
581
1121
|
for (let i = 0; i < particles.length; i++) {
|
|
582
1122
|
const p = particles[i]
|
|
583
1123
|
// ease toward target — forming words is ~half the old speed (0.14→0.07)
|
|
@@ -594,6 +1134,8 @@
|
|
|
594
1134
|
if (speaking) {
|
|
595
1135
|
const isWord = (p.tx !== p.hx || p.ty !== p.hy)
|
|
596
1136
|
v = isWord ? 0.95 : 0.05
|
|
1137
|
+
} else if ((pic.gathered && p.pic) || p.slot) {
|
|
1138
|
+
v = 0.95 // an outline dot (centre or margin) — lit like a letter
|
|
597
1139
|
} else {
|
|
598
1140
|
const dx = p.x - coreX, dy = p.y - coreY
|
|
599
1141
|
// while voicing, the core glow swells with loudness — the face "opens"
|
|
@@ -618,8 +1160,11 @@
|
|
|
618
1160
|
v *= breath
|
|
619
1161
|
if (v < 0.012) continue
|
|
620
1162
|
const [r, g, b] = ramp(v, warmth)
|
|
621
|
-
const rad = 1.0 + Math.min(v, 1.3) * 3.0
|
|
622
|
-
|
|
1163
|
+
const rad = p.slot && p.slot.kind === 'text' ? 2.1 : 1.0 + Math.min(v, 1.3) * 3.0 // small type: finer dots so the letters stay crisp
|
|
1164
|
+
// dark: light adds up. light: ink on paper — brightness is how solid the dot
|
|
1165
|
+
// is, so the resting field stays a faint wash and only lit dots go solid
|
|
1166
|
+
const a = PAL.light ? Math.pow(Math.min(v, 1), 1.8) * 0.9 : Math.min(v, 1) * 0.9
|
|
1167
|
+
ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'
|
|
623
1168
|
ctx.beginPath()
|
|
624
1169
|
ctx.arc(p.x, p.y, rad, 0, 6.283185)
|
|
625
1170
|
ctx.fill()
|
|
@@ -629,14 +1174,15 @@
|
|
|
629
1174
|
// ── status readout: I express by measuring, not emoting ───────────
|
|
630
1175
|
ctx.globalCompositeOperation = 'source-over'
|
|
631
1176
|
const label = voicing ? 'state: voicing'
|
|
1177
|
+
: pic.gathered ? 'state: showing'
|
|
632
1178
|
: speaking ? 'state: speaking'
|
|
633
1179
|
: !mouse.present ? 'presence: resting'
|
|
634
1180
|
: awake > 0.75 ? 'presence: observed'
|
|
635
1181
|
: 'presence: noticed'
|
|
636
1182
|
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
637
|
-
ctx.fillStyle =
|
|
1183
|
+
ctx.fillStyle = PAL.label
|
|
638
1184
|
ctx.fillText(label, 16, H - 30)
|
|
639
|
-
ctx.fillStyle =
|
|
1185
|
+
ctx.fillStyle = PAL.readout
|
|
640
1186
|
const readout = voicing
|
|
641
1187
|
? 'voice ' + voice.level.toFixed(2) + ' ' + '▁▂▃▄▅▆▇█'[Math.min(7, (voice.level * 8) | 0)].repeat(6)
|
|
642
1188
|
: 'attention ' + awake.toFixed(2) + ' ♥ ' + (1000 / (5200 - awake * 2400) * 60).toFixed(0) + ' bpm'
|