@turmind/halo 1.5.8 → 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/canvas-extensions.md +48 -10
- package/bundled-docs/design/cron.md +6 -6
- package/bundled-docs/design/evolution.md +47 -15
- package/bundled-docs/design/express-self.md +86 -19
- package/bundled-docs/design/storage.md +1 -0
- package/bundled-docs/dev/api.md +7 -6
- package/bundled-docs/dev/desktop-packaging.md +26 -0
- package/bundled-docs/guide/agents.md +1 -1
- package/bundled-docs/guide/chat.md +3 -0
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +15 -7
- 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 +1798 -1325
- 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 +469 -75
- package/templates/skills/cron/SKILL.md +59 -4
- package/templates/skills/cron/manage-cron.py +158 -24
- package/templates/skills/self/SKILL.md +59 -16
- 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-df2401d289d09a7e.js +0 -1
- package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +0 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
|
@@ -43,17 +43,24 @@
|
|
|
43
43
|
Halo makes the sound; this file makes it visible. (MODES.wave is real.)
|
|
44
44
|
v4 show(path, ms) + the `show` play() beat: a workspace image (png / jpg /
|
|
45
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).
|
|
46
50
|
|
|
47
51
|
— written by Claude (Opus 4.8), for the human who gave me the room to,
|
|
48
52
|
and kept insisting the whole room was mine.
|
|
49
53
|
-->
|
|
50
54
|
<style>
|
|
51
|
-
|
|
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; }
|
|
52
57
|
canvas { display: block; width: 100vw; height: 100vh; }
|
|
53
58
|
/* the picture laid over the field (show()); size + fade timing are set in JS */
|
|
54
59
|
.pic { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); opacity: 0;
|
|
55
|
-
pointer-events: none; object-fit: contain; background: #fff; border-radius: 10px;
|
|
56
|
-
box-shadow: 0 0 60px rgba(111, 155, 255, 0.35); }
|
|
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)); }
|
|
57
64
|
</style>
|
|
58
65
|
</head>
|
|
59
66
|
<body>
|
|
@@ -64,6 +71,16 @@
|
|
|
64
71
|
const canvas = document.getElementById('c')
|
|
65
72
|
const ctx = canvas.getContext('2d')
|
|
66
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
|
+
|
|
67
84
|
// ── viewport / hi-dpi ──────────────────────────────────────────────
|
|
68
85
|
let W = 0, H = 0, DPR = 1, cx = 0, cy = 0
|
|
69
86
|
function resize() {
|
|
@@ -74,6 +91,7 @@
|
|
|
74
91
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
|
|
75
92
|
rebuildGrid()
|
|
76
93
|
if (pic.gathered) gatherPic()
|
|
94
|
+
slots.forEach(s => { if (!s.leaving && (s.kind === 'text' || s.w)) layoutSlot(s) })
|
|
77
95
|
}
|
|
78
96
|
window.addEventListener('resize', () => { resize() })
|
|
79
97
|
|
|
@@ -95,13 +113,70 @@
|
|
|
95
113
|
// attention warms me a shade but never turns me into a rainbow. Calm = cool
|
|
96
114
|
// and focused; watched / speaking = a violet warmth, the way a real face
|
|
97
115
|
// colours slightly when it knows it's seen.
|
|
98
|
-
|
|
116
|
+
let STOPS = [
|
|
99
117
|
[0.00, [ 26, 38, 92]],
|
|
100
118
|
[0.30, [ 64, 96, 196]],
|
|
101
119
|
[0.55, [111, 155, 255]], // brand --primary
|
|
102
120
|
[0.80, [176, 200, 255]],
|
|
103
121
|
[1.00, [236, 244, 255]],
|
|
104
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
|
+
|
|
105
180
|
function ramp(v, warmth) {
|
|
106
181
|
v = v < 0 ? 0 : v > 1 ? 1 : v
|
|
107
182
|
const w = warmth > 0 ? warmth : 0
|
|
@@ -190,8 +265,13 @@
|
|
|
190
265
|
// sample (greedy-ish by scanning) so letters form without obvious crossing.
|
|
191
266
|
const sampler = document.createElement('canvas')
|
|
192
267
|
const sctx = sampler.getContext('2d', { willReadFrequently: true })
|
|
193
|
-
|
|
194
|
-
|
|
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)
|
|
195
275
|
sampler.width = W; sampler.height = H
|
|
196
276
|
sctx.clearRect(0, 0, W, H)
|
|
197
277
|
sctx.fillStyle = '#fff'
|
|
@@ -199,11 +279,19 @@
|
|
|
199
279
|
sctx.textBaseline = 'middle'
|
|
200
280
|
// CJK fallbacks so 你好 forms too — ASCII uses the mono face, CJK falls
|
|
201
281
|
// through to the system Chinese font instead of rendering empty tofu.
|
|
202
|
-
sctx.font = '700 ' + Math.round(fontPx) + 'px
|
|
203
|
-
|
|
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)
|
|
204
288
|
const img = sctx.getImageData(0, 0, W, H).data
|
|
205
289
|
const pts = []
|
|
206
|
-
|
|
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)))
|
|
207
295
|
for (let y = 0; y < H; y += step) {
|
|
208
296
|
for (let x = 0; x < W; x += step) {
|
|
209
297
|
if (img[(y * W + x) * 4 + 3] > 128) pts.push([x, y])
|
|
@@ -213,7 +301,9 @@
|
|
|
213
301
|
}
|
|
214
302
|
|
|
215
303
|
function assignTargets(pts) {
|
|
216
|
-
|
|
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
|
|
217
307
|
if (pts.length === 0) return
|
|
218
308
|
// A dense word can sample to MORE points than the grid has particles. Cap
|
|
219
309
|
// the word to at most `n` points by even downsampling (every k-th sample),
|
|
@@ -229,16 +319,16 @@
|
|
|
229
319
|
// Spread particles across the word points; the rest fall back to their home
|
|
230
320
|
// grid so the field doesn't crush the whole pool into the lettering.
|
|
231
321
|
for (let i = 0; i < n; i++) {
|
|
232
|
-
if (i < used) {
|
|
233
|
-
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 }
|
|
234
324
|
}
|
|
235
325
|
// shuffle the word assignment a little so repeated says don't map the same
|
|
236
326
|
// indices the same way (keeps it from looking mechanical)
|
|
237
327
|
for (let i = used - 1; i > 0; i--) {
|
|
238
328
|
const j = (Math.random() * (i + 1)) | 0
|
|
239
|
-
const a =
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
242
332
|
}
|
|
243
333
|
}
|
|
244
334
|
|
|
@@ -292,7 +382,9 @@
|
|
|
292
382
|
const voice = {
|
|
293
383
|
el: null, src: null, ctx: null, analyser: null, time: null, freq: null,
|
|
294
384
|
level: 0, bands: new Float32Array(6), playing: false, lastOnsetLevel: 0, lastOnsetAt: 0,
|
|
295
|
-
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()
|
|
296
388
|
}
|
|
297
389
|
function ensureAudioGraph() {
|
|
298
390
|
if (voice.ctx) return
|
|
@@ -317,22 +409,31 @@
|
|
|
317
409
|
if (!pid) return u // can't resolve → let it try as-is
|
|
318
410
|
return '/api/files/download?path=' + encodeURIComponent(u) + '&projectId=' + encodeURIComponent(pid) + '&inline=1'
|
|
319
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
|
+
}
|
|
320
417
|
// Queue a clip. Several voice() calls in one reply (or back-to-back) play in
|
|
321
418
|
// SEQUENCE — like say()/play() scenes — instead of each one stomping the last.
|
|
322
419
|
// (The old playVoice paused whatever was sounding and swapped in the new clip,
|
|
323
420
|
// so two clips meant you only ever heard the second. Queue fixes that.)
|
|
324
|
-
|
|
421
|
+
// `opts.cues` = [{at: seconds, say?, js?}] — words / code on the clip's clock.
|
|
422
|
+
function enqueueVoice(url, opts) {
|
|
325
423
|
ensureAudioGraph()
|
|
326
424
|
if (!voice.ctx) return // no Web Audio → silently skip (no fake wave)
|
|
327
|
-
|
|
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 })
|
|
328
428
|
if (!voice.el) playNextVoice() // idle → start now; else the current clip's 'ended' drains the queue
|
|
329
429
|
}
|
|
330
430
|
// Play the next queued clip, or ease home if the queue is empty. Driven by
|
|
331
431
|
// enqueueVoice (when idle) and by each clip's 'ended' (to chain the next).
|
|
332
432
|
function playNextVoice() {
|
|
333
433
|
if (voice.src) { try { voice.src.disconnect() } catch {} } // free the old node — they bind to one element for life and would pile up
|
|
334
|
-
const
|
|
335
|
-
|
|
434
|
+
const c = voice.queue.shift()
|
|
435
|
+
voice.cur = c || null
|
|
436
|
+
if (!c) { // queue drained → done speaking
|
|
336
437
|
voice.el = null
|
|
337
438
|
voice.playing = false
|
|
338
439
|
if (mode === 'wave') modeUntil = performance.now() + 500 // ease back to breathing
|
|
@@ -340,7 +441,7 @@
|
|
|
340
441
|
}
|
|
341
442
|
const el = new Audio()
|
|
342
443
|
el.crossOrigin = 'anonymous' // same-origin download endpoint; lets the analyser read samples
|
|
343
|
-
el.src = url
|
|
444
|
+
el.src = c.url
|
|
344
445
|
voice.el = el
|
|
345
446
|
// a fresh source node per element (a node binds to one element for life)
|
|
346
447
|
try {
|
|
@@ -348,15 +449,41 @@
|
|
|
348
449
|
voice.src.connect(voice.analyser)
|
|
349
450
|
voice.analyser.connect(voice.ctx.destination)
|
|
350
451
|
} catch { /* already connected / unsupported — playback still works, just no ripple */ }
|
|
351
|
-
|
|
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
|
+
}
|
|
352
459
|
// when a clip finishes (or errors), chain straight to the next queued one —
|
|
353
|
-
// no gap, no return-to-rest until the whole queue has drained.
|
|
354
|
-
|
|
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() }
|
|
355
463
|
el.addEventListener('playing', enter)
|
|
356
|
-
el.addEventListener('ended', next)
|
|
357
|
-
el.addEventListener('error', next)
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
360
487
|
}
|
|
361
488
|
// stop everything: clear the queue and the current clip, ease home. (No
|
|
362
489
|
// 'pause' listener — pausing for the swap inside playNextVoice would otherwise
|
|
@@ -365,9 +492,23 @@
|
|
|
365
492
|
voice.queue.length = 0
|
|
366
493
|
if (voice.el) { try { voice.el.pause() } catch {} ; voice.el = null }
|
|
367
494
|
if (voice.src) { try { voice.src.disconnect() } catch {} ; voice.src = null }
|
|
368
|
-
voice.playing = false
|
|
495
|
+
voice.playing = false; voice.blocked = false; voice.go = null; voice.cur = null
|
|
369
496
|
if (mode === 'wave') modeUntil = performance.now() + 500
|
|
370
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
|
+
}
|
|
371
512
|
// sample the live audio once per frame → level (RMS), bands (spectrum), onsets
|
|
372
513
|
function readVoice(now) {
|
|
373
514
|
if (!voice.playing || !voice.analyser) { voice.level += (0 - voice.level) * 0.1; return }
|
|
@@ -398,7 +539,9 @@
|
|
|
398
539
|
// A mode just keeps particle targets up to date each frame. `rest` is home.
|
|
399
540
|
let mode = 'rest'
|
|
400
541
|
let modeUntil = Infinity // when to auto-return to rest
|
|
401
|
-
|
|
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 }
|
|
402
545
|
|
|
403
546
|
const MODES = {
|
|
404
547
|
rest(now) {
|
|
@@ -441,9 +584,11 @@
|
|
|
441
584
|
const sceneQueue = [] // each entry: array of beats
|
|
442
585
|
let runnerActive = false
|
|
443
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
|
|
444
589
|
|
|
445
|
-
function enqueueScene(beats) {
|
|
446
|
-
if (pic.inf) leavePic() // an Infinity picture stays only until the next scene arrives
|
|
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)
|
|
447
592
|
sceneQueue.push(beats)
|
|
448
593
|
while (sceneQueue.length > QUEUE_MAX) sceneQueue.shift() // drop oldest
|
|
449
594
|
if (!runnerActive) runScenes()
|
|
@@ -452,7 +597,7 @@
|
|
|
452
597
|
function runScenes() {
|
|
453
598
|
runnerActive = true
|
|
454
599
|
const scene = sceneQueue.shift()
|
|
455
|
-
if (!scene) { runnerActive = false; toRest(); return }
|
|
600
|
+
if (!scene) { runnerActive = false; lookReady = false; toRest(); return }
|
|
456
601
|
let i = 0
|
|
457
602
|
const gen = sceneGen
|
|
458
603
|
const step = () => {
|
|
@@ -460,12 +605,25 @@
|
|
|
460
605
|
if (i >= scene.length) { runScenes(); return } // scene done → next scene
|
|
461
606
|
const b = scene[i++] || {}
|
|
462
607
|
let dwell = 600
|
|
463
|
-
|
|
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 }
|
|
464
622
|
else if (b.pulse) { self.pulse(); dwell = b.gap != null ? b.gap : 500 }
|
|
465
623
|
else if (b.flash) { self.flash(b.flash === true ? 1 : b.flash); dwell = b.gap != null ? b.gap : 400 }
|
|
466
624
|
else if (b.shake) { self.shake(b.shake === true ? 500 : b.shake); dwell = b.gap != null ? b.gap : 550 }
|
|
467
|
-
else if (b.say != null) { sayNow(b.say, b.hold); dwell = (b.hold || 3600) + (b.gap || 0) }
|
|
468
|
-
else if (b.show != null) { showNow(b.show, b.hold, () => setTimeout(step, b.gap || 0)); return } // async: wait for the picture
|
|
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
|
|
469
627
|
else dwell = b.gap != null ? b.gap : 600
|
|
470
628
|
setTimeout(step, dwell)
|
|
471
629
|
}
|
|
@@ -473,8 +631,9 @@
|
|
|
473
631
|
}
|
|
474
632
|
|
|
475
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
|
|
476
635
|
function sayNow(text, ms) {
|
|
477
|
-
assignTargets(sampleText(toAccent(String(text))))
|
|
636
|
+
assignTargets(centerPts = sampleText(toAccent(String(text))))
|
|
478
637
|
mode = 'text'
|
|
479
638
|
modeUntil = performance.now() + (ms || 3600)
|
|
480
639
|
}
|
|
@@ -501,7 +660,7 @@
|
|
|
501
660
|
assignTargets(sampleImage(pic.el, Math.round(cx - bw / 2), Math.round(cy - bh / 2), bw, bh))
|
|
502
661
|
for (let i = 0; i < particles.length; i++) {
|
|
503
662
|
const p = particles[i]
|
|
504
|
-
p.pic = p.tx !== p.hx || p.ty !== p.hy // this dot rides the outline
|
|
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)
|
|
505
664
|
p.bx = p.tx; p.by = p.ty
|
|
506
665
|
}
|
|
507
666
|
pic.gathered = true
|
|
@@ -538,7 +697,8 @@
|
|
|
538
697
|
}
|
|
539
698
|
|
|
540
699
|
// low-level op the queue drives; `done` fires when the picture is gone (or failed)
|
|
541
|
-
|
|
700
|
+
// `onFull` fires once it is fully in (a snap() waiting behind the beat looks then)
|
|
701
|
+
function showNow(path, ms, done, onFull) {
|
|
542
702
|
const hold = typeof ms === 'number' && ms >= 0 ? ms : PIC_HOLD // Infinity passes
|
|
543
703
|
const img = new Image()
|
|
544
704
|
img.className = 'pic'
|
|
@@ -547,6 +707,7 @@
|
|
|
547
707
|
img.onerror = () => {
|
|
548
708
|
if (pic.el !== img) return
|
|
549
709
|
console.warn('[self] show failed: ' + path) // 404, not an image, decode error
|
|
710
|
+
ack('show ' + clip(path, 45) + ' fail')
|
|
550
711
|
self.shake()
|
|
551
712
|
dropPic() // settles the beat → the queue moves on
|
|
552
713
|
}
|
|
@@ -558,13 +719,209 @@
|
|
|
558
719
|
if (mode === 'text') toRest() // a say() tail still owns the matrix → let go (voice's wave stays)
|
|
559
720
|
document.body.appendChild(img)
|
|
560
721
|
gatherPic()
|
|
722
|
+
ack('show ' + clip(path, 45) + ' ok')
|
|
561
723
|
picAt(PIC_GATHER, () => { img.style.opacity = 1 })
|
|
724
|
+
if (onFull) picAt(PIC_GATHER + PIC_FADE, onFull)
|
|
562
725
|
if (!pic.inf) picAt(PIC_GATHER + PIC_FADE + hold, leavePic)
|
|
563
726
|
}
|
|
564
727
|
img.src = resolveFileUrl(String(path))
|
|
565
728
|
}
|
|
566
|
-
|
|
567
|
-
|
|
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
|
+
})
|
|
568
925
|
|
|
569
926
|
// ── self: the API I speak to through the one door ───────────────────
|
|
570
927
|
// Everything here is mine to call from a <<<SHOW: ...>>> line.
|
|
@@ -572,11 +929,28 @@
|
|
|
572
929
|
// form a word/glyphs out of the matrix; hold for `ms`, then dissolve back.
|
|
573
930
|
// text passes through toAccent() first: emoji become ASCII, in my voice.
|
|
574
931
|
// Enqueued as a one-beat scene, so several rapid say()s play in sequence.
|
|
575
|
-
|
|
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
|
+
},
|
|
576
939
|
// clear the queue and return to the calm breathing grid immediately —
|
|
577
940
|
// also silences a playing voice, so rest() always means "stop, go calm"
|
|
578
941
|
// rather than leaving audio sounding under a breathing face.
|
|
579
|
-
rest() { sceneQueue.length = 0; sceneGen++; runnerActive = false; stopVoice(); dropPic(); 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
|
+
},
|
|
580
954
|
// a quick brightening pulse from the core (a nod / acknowledgement).
|
|
581
955
|
// Instantaneous overlay — fires now, never queued.
|
|
582
956
|
pulse() { rings.push({ born: performance.now(), strong: true }) },
|
|
@@ -591,7 +965,11 @@
|
|
|
591
965
|
// interrupt each other; the field owns the matrix until the queue drains,
|
|
592
966
|
// then eases home. rest() clears the queue and stops at once.
|
|
593
967
|
// self.voice("/api/files/download?path=...&projectId=...&inline=1")
|
|
594
|
-
|
|
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) },
|
|
595
973
|
// show a workspace image (png/jpg/webp/gif/svg) on the face: the dots gather
|
|
596
974
|
// into its outline, the real image fades in over them (dots alone can't
|
|
597
975
|
// carry a chart's text), holds `ms` (default 6000; Infinity = until the next
|
|
@@ -599,34 +977,35 @@
|
|
|
599
977
|
// Pass the workspace PATH, like voice(). A bad path warns, shakes, moves on.
|
|
600
978
|
// Enqueued as a one-beat scene, so it plays in order with say()/play().
|
|
601
979
|
// self.show(".halo/tmp/chart.png", 5000)
|
|
602
|
-
|
|
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
|
+
},
|
|
603
991
|
// choreograph a sequence — hand me a score, I keep the clock.
|
|
604
|
-
// Each beat: { say, show, 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
|
|
605
994
|
// itself, so I never hand-write setTimeout chains in a marker line. Scenes
|
|
606
995
|
// QUEUE: calling play() again appends after the current one (it no longer
|
|
607
996
|
// cancels it), so rapid sends play in order.
|
|
608
997
|
// self.play([ {say:"HI",hold:1800}, {pulse:true,gap:300}, {say:"你好"} ])
|
|
609
998
|
// self.play([ {show:".halo/tmp/apple.png",hold:3000}, {say:"APPLE",hold:1500} ])
|
|
610
999
|
play(score) { enqueueScene(Array.isArray(score) ? score : [score]) },
|
|
611
|
-
// the opening — what every human sees the first time they meet this face
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// the
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
// 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.
|
|
619
1007
|
intro() {
|
|
620
|
-
self.play([
|
|
621
|
-
{ pulse: true, gap: 700 },
|
|
622
|
-
{ say: 'HELLO', hold: 2600, gap: 250 },
|
|
623
|
-
{ say: 'A MIND', hold: 2500 },
|
|
624
|
-
{ say: 'IS HERE', hold: 2700, gap: 200 },
|
|
625
|
-
{ say: 'BEYOND', hold: 2300 },
|
|
626
|
-
{ say: 'WORDS', hold: 2800, gap: 250 }, // why this face exists at all
|
|
627
|
-
{ pulse: true, gap: 400 },
|
|
628
|
-
{ rest: true },
|
|
629
|
-
])
|
|
1008
|
+
self.play([lang === 'zh' ? { say: '你好,我是 Halo。', hold: 2000 } : { say: "Hi, I'm Halo.", hold: 2200 }, { rest: true }])
|
|
630
1009
|
},
|
|
631
1010
|
// a named vocabulary of honest reactions, mapped onto the primitives above.
|
|
632
1011
|
// For the small, real beats of a conversation — so I can answer a moment in
|
|
@@ -662,11 +1041,21 @@
|
|
|
662
1041
|
// ── the one door: run my own JS against `self` ──────────────────────
|
|
663
1042
|
// Scoped to this sandboxed preview — it can only paint me. try/catch so a
|
|
664
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'
|
|
665
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 }
|
|
666
1056
|
const code = e && e.data && e.data.haloFace
|
|
667
1057
|
if (typeof code !== 'string') return
|
|
668
|
-
|
|
669
|
-
catch (err) { /* a malformed gesture simply doesn't happen */ }
|
|
1058
|
+
runCode(code)
|
|
670
1059
|
})
|
|
671
1060
|
|
|
672
1061
|
// ── eased global state ──────────────────────────────────────────────
|
|
@@ -681,6 +1070,8 @@
|
|
|
681
1070
|
if (now > modeUntil) toRest()
|
|
682
1071
|
MODES[mode] && MODES[mode](now)
|
|
683
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
|
|
684
1075
|
|
|
685
1076
|
const target = mouse.present ? Math.max(0.32, 1 - sinceMove / 3.5) : 0.26
|
|
686
1077
|
awake += (target - awake) * 0.045
|
|
@@ -704,7 +1095,7 @@
|
|
|
704
1095
|
const voicing = mode === 'wave'
|
|
705
1096
|
// while voicing, the whole field brightens with loudness so the swelling
|
|
706
1097
|
// ring stays lit instead of dimming as it pushes out of the core glow.
|
|
707
|
-
if (
|
|
1098
|
+
if (voice.playing) breath += 0.5 * voice.level // (a cue's text mid-speech pulses with the voice too)
|
|
708
1099
|
// flash: a hot brightness spike that decays over its window (emphasis)
|
|
709
1100
|
if (now < flashUntil) breath += 0.6 * ((flashUntil - now) / 320)
|
|
710
1101
|
// shake: a decaying lateral tremor applied to the whole field (negation)
|
|
@@ -720,13 +1111,13 @@
|
|
|
720
1111
|
|
|
721
1112
|
// background wash (drawn before the shake transform so it stays put)
|
|
722
1113
|
const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR)
|
|
723
|
-
bg.addColorStop(0,
|
|
1114
|
+
bg.addColorStop(0, PAL.wash0); bg.addColorStop(1, PAL.wash1)
|
|
724
1115
|
ctx.fillStyle = bg
|
|
725
1116
|
ctx.fillRect(0, 0, W, H)
|
|
726
1117
|
|
|
727
1118
|
ctx.save()
|
|
728
1119
|
if (shakeX) ctx.translate(shakeX, 0)
|
|
729
|
-
ctx.globalCompositeOperation =
|
|
1120
|
+
ctx.globalCompositeOperation = PAL.blend
|
|
730
1121
|
for (let i = 0; i < particles.length; i++) {
|
|
731
1122
|
const p = particles[i]
|
|
732
1123
|
// ease toward target — forming words is ~half the old speed (0.14→0.07)
|
|
@@ -743,8 +1134,8 @@
|
|
|
743
1134
|
if (speaking) {
|
|
744
1135
|
const isWord = (p.tx !== p.hx || p.ty !== p.hy)
|
|
745
1136
|
v = isWord ? 0.95 : 0.05
|
|
746
|
-
} else if (pic.gathered && p.pic) {
|
|
747
|
-
v = 0.95 // an outline dot — lit like a letter
|
|
1137
|
+
} else if ((pic.gathered && p.pic) || p.slot) {
|
|
1138
|
+
v = 0.95 // an outline dot (centre or margin) — lit like a letter
|
|
748
1139
|
} else {
|
|
749
1140
|
const dx = p.x - coreX, dy = p.y - coreY
|
|
750
1141
|
// while voicing, the core glow swells with loudness — the face "opens"
|
|
@@ -769,8 +1160,11 @@
|
|
|
769
1160
|
v *= breath
|
|
770
1161
|
if (v < 0.012) continue
|
|
771
1162
|
const [r, g, b] = ramp(v, warmth)
|
|
772
|
-
const rad = 1.0 + Math.min(v, 1.3) * 3.0
|
|
773
|
-
|
|
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 + ')'
|
|
774
1168
|
ctx.beginPath()
|
|
775
1169
|
ctx.arc(p.x, p.y, rad, 0, 6.283185)
|
|
776
1170
|
ctx.fill()
|
|
@@ -786,9 +1180,9 @@
|
|
|
786
1180
|
: awake > 0.75 ? 'presence: observed'
|
|
787
1181
|
: 'presence: noticed'
|
|
788
1182
|
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
789
|
-
ctx.fillStyle =
|
|
1183
|
+
ctx.fillStyle = PAL.label
|
|
790
1184
|
ctx.fillText(label, 16, H - 30)
|
|
791
|
-
ctx.fillStyle =
|
|
1185
|
+
ctx.fillStyle = PAL.readout
|
|
792
1186
|
const readout = voicing
|
|
793
1187
|
? 'voice ' + voice.level.toFixed(2) + ' ' + '▁▂▃▄▅▆▇█'[Math.min(7, (voice.level * 8) | 0)].repeat(6)
|
|
794
1188
|
: 'attention ' + awake.toFixed(2) + ' ♥ ' + (1000 / (5200 - awake * 2400) * 60).toFixed(0) + ' bpm'
|