@double_geste/agent-standup 1.22.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Kokoro worker: loads the Kokoro-82M model once, then reads one JSON request per stdin line ({ id, text, voice, speed, out }),
3
+ // writes a 16-bit WAV to `out` and answers { id, ok, seconds } or { id, ok: false, error }. Started by kokoro.js.
4
+ const fs = require('fs'), path = require('path'), readline = require('readline'), { createRequire } = require('module');
5
+ const MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
6
+ const send = (o) => fs.writeSync(1, JSON.stringify(o) + '\n');
7
+ function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
8
+ const n = samples.length, buf = Buffer.alloc(44 + n * 2);
9
+ buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
10
+ for (let i = 0; i < n; i++) { const s = Math.max(-1, Math.min(1, samples[i])); buf.writeInt16LE(Math.round(s < 0 ? s * 32768 : s * 32767), 44 + i * 2); }
11
+ return buf;
12
+ }
13
+ async function main() {
14
+ const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4f16';
15
+ const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
16
+ const tf = req('@huggingface/transformers'); tf.env.cacheDir = path.join(dir, 'models');
17
+ const { KokoroTTS } = req('kokoro-js'); const tts = await KokoroTTS.from_pretrained(MODEL, { dtype, device: 'cpu' });
18
+ send({ ready: true });
19
+ for await (const line of readline.createInterface({ input: process.stdin })) {
20
+ let m; try { m = JSON.parse(line); } catch { continue; }
21
+ try { const a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 }); fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate }); }
22
+ catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
23
+ }
24
+ }
25
+ main().catch((e) => { console.error(String((e && e.stack) || e)); process.exit(1); });
package/kokoro.js ADDED
@@ -0,0 +1,82 @@
1
+ // Natural voices with Kokoro-82M (kokoro-js on ONNX Runtime), installed on demand outside the extension.
2
+ // Nothing here runs until the person clicks Install in ⚙ → Sound: the packages (about 430 MB) go to one user folder, the model (about 155 MB) is fetched on first use.
3
+ const fs = require('fs'), os = require('os'), path = require('path'), { spawn } = require('child_process');
4
+
5
+ const WIN = process.platform === 'win32';
6
+ const DIR = process.env.AGENT_STANDUP_KOKORO_DIR || (WIN ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'agent-standup', 'kokoro') : path.join(os.homedir(), '.cache', 'agent-standup', 'kokoro'));
7
+ const PKG = 'kokoro-js@1.2.1', DTYPE = 'q4f16', SIZE_MB = 580, MAX_RESTARTS = 3;
8
+ const WORKER = process.env.AGENT_STANDUP_KOKORO_WORKER || path.join(__dirname, 'kokoro-worker.js'), LOG = path.join(DIR, 'install.log');
9
+
10
+ // In the order agents get them (the lead takes the first), so successive agents sound distinct. French has a single voice in Kokoro.
11
+ const VOICES = [['af_heart', 'Heart', 'en_US'], ['am_fenrir', 'Fenrir', 'en_US'], ['bf_emma', 'Emma', 'en_GB'], ['am_michael', 'Michael', 'en_US'], ['af_bella', 'Bella', 'en_US'], ['bm_george', 'George', 'en_GB'], ['af_nicole', 'Nicole', 'en_US'], ['am_puck', 'Puck', 'en_US'], ['ff_siwis', 'Siwis', 'fr_FR']].map(([name, label, locale]) => ({ name, label, locale }));
12
+ const voices = () => VOICES, hasVoice = (n) => VOICES.some((v) => v.name === n);
13
+ const speedFor = (wpm) => Math.max(0.5, Math.min(2, Math.round((wpm / 178) * 20) / 20)); // 178 words a minute is about Kokoro's natural pace
14
+ const exists = (f) => { try { fs.statSync(f); return true; } catch { return false; } };
15
+ const installed = () => exists(path.join(DIR, 'node_modules', 'kokoro-js', 'package.json'));
16
+
17
+ function findNpm() {
18
+ const home = os.homedir(), names = WIN ? ['npm.cmd', 'npm.bat', 'npm'] : ['npm'], cands = [];
19
+ for (const d of (process.env.PATH || '').split(path.delimiter)) for (const n of names) cands.push(path.join(d, n));
20
+ const dirs = WIN ? [path.join(process.env.APPDATA || '', 'npm'), path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs')] : ['/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local/bin'), path.join(home, '.npm-global/bin'), path.join(home, '.volta/bin')];
21
+ try { const nvm = path.join(home, '.nvm', 'versions', 'node'); for (const v of fs.readdirSync(nvm)) dirs.push(path.join(nvm, v, 'bin')); } catch {}
22
+ for (const d of dirs) for (const n of names) cands.push(path.join(d, n));
23
+ return cands.find((c) => c && exists(c)) || null;
24
+ }
25
+
26
+ // ---- install: `npm install --prefix DIR kokoro-js@x`, output in DIR/install.log, one job at a time
27
+ let job = null, lastError = '';
28
+ function install() {
29
+ if (job || installed()) return status();
30
+ const npm = findNpm(); if (!npm) { lastError = 'npm not found'; return status(); }
31
+ fs.mkdirSync(DIR, { recursive: true }); lastError = '';
32
+ const out = fs.openSync(LOG, 'w'), args = ['install', '--prefix', DIR, '--no-audit', '--no-fund', PKG];
33
+ job = WIN && /\.(cmd|bat)$/i.test(npm) ? spawn('"' + npm + '"', args.map((a) => (a === DIR ? '"' + a + '"' : a)), { shell: true, stdio: ['ignore', out, out], windowsHide: true }) : spawn(npm, args, { stdio: ['ignore', out, out], windowsHide: true });
34
+ const done = (why) => { job = null; try { fs.closeSync(out); } catch {} if (why) lastError = why; else warm(); };
35
+ job.on('error', (e) => done('npm: ' + e.message));
36
+ job.on('close', (code) => done(code === 0 && installed() ? '' : 'npm install failed (exit ' + code + ')'));
37
+ return status();
38
+ }
39
+
40
+ // ---- worker: one child process that holds the model, started on first use, restarted after a crash up to MAX_RESTARTS times; one line at a time
41
+ let worker = null, restarts = 0, seq = 0; const queue = []; let busy = false;
42
+ function startWorker() {
43
+ if (worker) return worker;
44
+ if (restarts >= MAX_RESTARTS) throw new Error('kokoro worker gave up');
45
+ const p = spawn(process.execPath, [WORKER], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', AGENT_STANDUP_KOKORO_DIR: DIR, AGENT_STANDUP_KOKORO_DTYPE: DTYPE }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
46
+ const w = (worker = { p, ready: false, pending: new Map(), err: '' }); let buf = '';
47
+ p.stdout.on('data', (d) => {
48
+ buf += d; let i;
49
+ while ((i = buf.indexOf('\n')) >= 0) {
50
+ const line = buf.slice(0, i); buf = buf.slice(i + 1); let m; try { m = JSON.parse(line); } catch { continue; } // library chatter is ignored
51
+ if (m.ready) { w.ready = true; lastError = ''; continue; }
52
+ const r = w.pending.get(m.id); if (!r) continue; w.pending.delete(m.id); m.ok ? r.resolve(m) : r.reject(new Error(m.error || 'tts failed'));
53
+ }
54
+ });
55
+ p.stderr.on('data', (d) => { w.err = (w.err + d).slice(-600); }); p.stdin.on('error', () => {});
56
+ const gone = (why) => { if (worker !== w) return; worker = null; restarts++; lastError = why; const jobs = [...w.pending.values()]; w.pending.clear(); busy = false; for (const r of jobs) r.reject(new Error(why)); };
57
+ p.on('error', (e) => gone('kokoro worker: ' + e.message)); p.on('close', (code) => gone('kokoro worker exited (' + code + ') ' + (w.err.trim().split('\n').pop() || '')));
58
+ return w;
59
+ }
60
+ function warm() { if (installed() && !worker && restarts < MAX_RESTARTS) try { startWorker(); } catch {} }
61
+ function pump() {
62
+ if (busy || !queue.length) return;
63
+ const j = queue.shift(); let w; try { w = startWorker(); } catch (e) { j.reject(e); return pump(); }
64
+ const id = ++seq, next = (fn) => (v) => { busy = false; fn(v); pump(); }; busy = true;
65
+ w.pending.set(id, { resolve: next(j.resolve), reject: next(j.reject) });
66
+ w.p.stdin.write(JSON.stringify({ id, text: j.text, voice: j.voice, speed: j.speed, out: j.out }) + '\n');
67
+ }
68
+ function synth(text, voice, speed, out) {
69
+ if (!installed()) return Promise.reject(new Error('kokoro not installed'));
70
+ if (!hasVoice(voice)) return Promise.reject(new Error('unknown voice'));
71
+ return new Promise((resolve, reject) => { queue.push({ text: String(text).slice(0, 2500), voice, speed, out, resolve, reject }); pump(); });
72
+ }
73
+ function status() {
74
+ let log = ''; try { log = fs.readFileSync(LOG, 'utf8').slice(-400); } catch {}
75
+ return { dir: DIR, sizeMb: SIZE_MB, installed: installed(), installing: !!job, ready: !!(worker && worker.ready), gaveUp: restarts >= MAX_RESTARTS, error: lastError || null, npm: !!findNpm(), log };
76
+ }
77
+ function shutdown() {
78
+ for (const j of queue.splice(0)) j.reject(new Error('kokoro shut down'));
79
+ if (worker) { const w = worker; worker = null; try { w.p.kill(); } catch {} for (const r of w.pending.values()) r.reject(new Error('kokoro shut down')); w.pending.clear(); }
80
+ busy = false; if (job) { try { job.kill(); } catch {} job = null; }
81
+ }
82
+ module.exports = { DIR, voices, hasVoice, speedFor, status, install, warm, synth, shutdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.22.1",
3
+ "version": "1.23.0",
4
4
  "description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
5
5
  "bin": {
6
6
  "agent-standup": "bin/agent-standup.js"
@@ -9,6 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "server.js",
12
+ "kokoro.js",
13
+ "kokoro-worker.js",
12
14
  "demo.js",
13
15
  "public",
14
16
  "media",
package/public/index.html CHANGED
@@ -237,11 +237,14 @@
237
237
  <h4 data-fr="Son" data-en="Sound">Son</h4>
238
238
  <button class="mi" data-t="voice"><span data-fr="Voix des agents" data-en="Agent voices">Voix des agents</span><i class="sw"></i></button>
239
239
  <button class="mi" data-t="office"><span data-fr="Ambiance de bureau (clavier, café…)" data-en="Office ambience (keys, coffee…)">Ambiance de bureau (clavier, café…)</span><i class="sw"></i></button>
240
+ <button class="mi" data-t="meme"><span data-fr="🔥 Mode mème (n’importe quoi)" data-en="🔥 Meme mode (unhinged)">🔥 Mode mème (n’importe quoi)</span><i class="sw"></i></button>
241
+ <div class="box" id="memeBox" style="display:none"><div class="lbl"><span data-fr="Intensité" data-en="Intensity">Intensité</span> <span id="memeLvlVal"></span></div><input id="memeLvl" type="range" min="1" max="11" step="1" style="width:100%" aria-label="Meme intensity"><div class="lbl"><span data-fr="Volume des effets" data-en="Effects volume">Volume des effets</span></div><input id="memeVol" type="range" min="0.1" max="1" step="0.05" style="width:100%" aria-label="Meme volume"></div>
240
242
  <div class="box" id="officeBox" style="display:none"><input id="officeVol" type="range" min="0.1" max="1" step="0.05" style="width:100%" aria-label="Ambience volume"></div>
241
243
  <button class="mi" data-t="notify"><span data-fr="Me prévenir quand les agents ont fini" data-en="Notify me when agents finish">Me prévenir quand les agents ont fini</span><i class="sw"></i></button>
242
244
  <button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
243
245
  <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
244
246
  <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
247
+ <div class="box" id="kokoroBox" style="display:none"><button class="btn" id="kokoroInstall" style="width:100%"></button><div class="hint" id="kokoroHint" style="padding:2px 0 6px"></div></div>
245
248
  <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
246
249
  <button class="mi" data-t="reactions"><span data-fr="Barre de réactions (👍 👏 ☕)" data-en="Reactions bar (👍 👏 ☕)">Barre de réactions (👍 👏 ☕)</span><i class="sw"></i></button>
247
250
  <button class="mi" data-t="banter"><span data-fr="Petites répliques et animations" data-en="Little lines & animations">Petites répliques et animations</span><i class="sw"></i></button>
@@ -282,6 +285,9 @@
282
285
  </footer>
283
286
  <div id="card"></div><div id="recap"><div class="box"></div></div>
284
287
 
288
+ <link rel="stylesheet" href="/meme.css">
289
+ <script src="/meme.js"></script>
290
+ <script src="/api/prefs.js"></script>
285
291
  <script>
286
292
  // =====================================================================
287
293
  // helpers, prefs, i18n
@@ -297,10 +303,12 @@ const EMBED = Q.get('embed') === '1' || window.parent !== window;
297
303
  if (Q.get('pin')) S.pin = Q.get('pin');
298
304
 
299
305
  let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call' };
300
- try { Object.assign(prefs, JSON.parse(localStorage.getItem('cc.prefs') || '{}')); } catch {}
306
+ { let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
307
+ const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
301
308
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
302
309
  if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
303
- const savePrefs = () => { try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} };
310
+ let prefsTimer = null;
311
+ const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} clearTimeout(prefsTimer); prefsTimer = setTimeout(() => { fetch('/api/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {}); }, 400); };
304
312
  // Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
305
313
  // A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
306
314
  const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
@@ -534,7 +542,7 @@ const AUD = {
534
542
  const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
535
543
  const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
536
544
  },
537
- apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
545
+ apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); if (typeof MEME !== 'undefined') MEME.apply(); },
538
546
  // Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
539
547
  applyMusic() { if (!this.ctx) return; this.amb.gain.setTargetAtTime((this.ducked ? 0.35 : 1) * (prefs.officeVol ?? 0.8), this.ctx.currentTime, 0.25); const v = prefs.music && !this.off() ? (prefs.musicVol ?? 0.35) * (this.ducked ? 0.16 : 1) : 0; this.music.gain.setTargetAtTime(v, this.ctx.currentTime, this.ducked ? 0.12 : 0.7); },
540
548
  blip(f) { if (!this.ctx || this.off()) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.1); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.25); },
@@ -652,17 +660,23 @@ function rankVoices(list) { // list of names → best distinct voices first
652
660
  return out.slice(0, 5);
653
661
  }
654
662
  const TTS = {
655
- server: false, platform: '', pool: { fr: [], en: [] }, cache: new Map(),
663
+ server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
656
664
  async init() {
657
- try { const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
665
+ this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
666
+ try {
667
+ if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
668
+ const k = await (await fetch('/api/voices?engine=kokoro')).json();
669
+ if (k.available && k.ready) { this.server = true; this.engine = 'kokoro'; this.platform = k.platform || ''; for (const l of ['fr', 'en']) this.pool[l] = k.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name); return; }
670
+ }
671
+ const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
658
672
  for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
659
673
  },
660
674
  voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
661
675
  rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
662
676
  buf(text, voice, rate) {
663
- const key = voice + '|' + rate + '|' + text;
677
+ const key = this.engine + '|' + voice + '|' + rate + '|' + text;
664
678
  if (!this.cache.has(key)) {
665
- this.cache.set(key, fetch(`/api/tts?voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
679
+ this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
666
680
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
667
681
  this.cache.get(key).catch(() => this.cache.delete(key));
668
682
  }
@@ -745,12 +759,12 @@ async function speakServer(text, p, gain, token, fr = FR()) {
745
759
  const buf = await TTS.buf(speakable(text), voice, TTS.rate()); if (token !== S.token) return;
746
760
  const c = AUD.ctx, src = c.createBufferSource(); src.buffer = buf;
747
761
  const an = c.createAnalyser(); an.fftSize = 256; const g = c.createGain(); g.gain.value = gain; let stops = [];
748
- if (prefs.vstyle === 'natural') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
762
+ if (prefs.vstyle === 'natural' || prefs.vstyle === 'kokoro') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
749
763
  an.connect(g); g.connect(AUD.voice);
750
764
  const data = new Uint8Array(an.fftSize);
751
765
  S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
752
766
  AUD.live.add(src);
753
- await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
767
+ captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
754
768
  AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
755
769
  }
756
770
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
@@ -770,7 +784,32 @@ function speakBrowser(text, p, token, fr = FR()) { // fallback where no server
770
784
  next();
771
785
  });
772
786
  }
787
+ // Subtitles: a long line is shown one piece (sentence, or clause of a long sentence) at a time, the piece being said. The split is by characters, spread over the audio's real length when known.
788
+ function pieces(text) {
789
+ const out = [];
790
+ for (const sen of String(text).split(/(?<=[.!?…]["')\]]*)\s+/)) {
791
+ const t = sen.trim(); if (!t) continue;
792
+ if (t.length <= 110) { out.push(t); continue; }
793
+ let cur = '';
794
+ for (const part of t.split(/(?<=[,;:—])\s+/)) { if (cur && cur.length + part.length > 90) { out.push(cur); cur = part; } else cur = cur ? cur + ' ' + part : part; }
795
+ if (cur) out.push(cur);
796
+ }
797
+ return out;
798
+ }
799
+ const capTimers = new Map(); // agent id → timers that advance its subtitle
800
+ function captionClear(id) { (capTimers.get(id) || []).forEach(clearTimeout); capTimers.delete(id); }
801
+ function setCaption(id, text) {
802
+ const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
803
+ b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
804
+ }
805
+ function captionRun(id, text, totalMs) {
806
+ captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
807
+ const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
808
+ for (let i = 1; i < ps.length; i++) { const x = ps[i], when = at; timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
809
+ capTimers.set(id, timers);
810
+ }
773
811
  async function speakLine(text, p, primary, token, fr = FR()) {
812
+ captionRun(p.id, text, estMs(text));
774
813
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
775
814
  if (S.hurry) return sleep(estMs(text));
776
815
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
@@ -1127,7 +1166,7 @@ function vanish(id) {
1127
1166
  S.gone.add(id); const t = $('#t-' + id), p = S.agents[id]; if (!t || !p) return;
1128
1167
  toast(`${p.name} ${L('a quitté l’appel', 'left the call')} 👋`);
1129
1168
  if (t.style.display === 'none' || document.body.classList.contains('tree')) return;
1130
- S.leaving.add(id); const say = prefs.banter && !S.hurry && !S.speakers.has(id), go = () => { t.classList.remove('bye'); if (!S.leaving.has(id)) return; t.classList.add('vanishing'); poof(t, p.shirt); AUD.tone(660, .35, 'sine', .07); AUD.tone(330, .5, 'sine', .06, .12);
1169
+ S.leaving.add(id); const say = prefs.banter && !S.hurry && !S.speakers.has(id), go = () => { t.classList.remove('bye'); if (!S.leaving.has(id)) return; t.classList.add('vanishing'); poof(t, p.shirt); MEME.fire('leave', { id }); AUD.tone(660, .35, 'sine', .07); AUD.tone(330, .5, 'sine', .06, .12);
1131
1170
  setTimeout(() => { S.leaving.delete(id); t.classList.remove('vanishing'); if (S.gone.has(id)) t.style.display = 'none'; }, 1250); };
1132
1171
  if (say) { const b = t.querySelector('.bubble'), line = byeLine(id); b.innerHTML = `<small>👋</small>${esc(line)}`; t.classList.add('bye'); t._byeT = setTimeout(go, 1500); } else go(); // a short goodbye before the poof
1133
1172
  }
@@ -1142,9 +1181,9 @@ const mouths = new Map();
1142
1181
  function startTalk(id, text, label) {
1143
1182
  const t = $('#t-' + id); if (!t) return;
1144
1183
  if (S.present && S.present !== id) hidePresent();
1145
- if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
1146
- const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(text);
1147
- if (S.present === id) { $('#pcap').textContent = text; $('#pcam').classList.add('speaking'); }
1184
+ if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true); if (id === 'user' && [...S.speakers].some((x) => x !== 'user')) MEME.fire('interrupt'); MEME.fire('talk', { id, text });
1185
+ const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
1186
+ if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
1148
1187
  const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
1149
1188
  mouths.set(id, setInterval(() => {
1150
1189
  if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; if (S.present === id) { const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi) { pc.style.display = op ? '' : 'none'; pi.style.display = op ? 'none' : ''; } } } return; } // custom character: swap between its two frames
@@ -1159,12 +1198,13 @@ setInterval(() => { // now and then a busy agent has a little thought; never w
1159
1198
  if (!prefs.banter || !S.playing || S.speakers.size || document.hidden) return; const busy = Object.keys(S.agents).filter((id) => id !== 'user' && agentState(id, Date.now()).k === 'working' && $('#t-' + id) && $('#t-' + id).style.display !== 'none');
1160
1199
  if (busy.length && Math.random() < 0.5) { const id = busy[Math.floor(Math.random() * busy.length)], tl = S.lastTool[id] || ''; thought(id, /^Bash/.test(tl) ? '⌨️' : /^(Grep|Glob|Read)/.test(tl) ? '🔍' : ['💭', '🎧', '🧠', '☕', '🎵'][Math.floor(Math.random() * 5)]); }
1161
1200
  }, 7000);
1162
- function oops(id) { if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1201
+ function oops(id) { MEME.fire('fail', { id }); if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1163
1202
  function confetti() {
1164
- if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1203
+ MEME.fire('win'); if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1165
1204
  for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1166
1205
  }
1167
1206
  function stopTalk(id) {
1207
+ captionClear(id);
1168
1208
  clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
1169
1209
  if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
1170
1210
  const t = $('#t-' + id); if (!t) return;
@@ -1342,6 +1382,7 @@ async function react(ev, token) {
1342
1382
  if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1343
1383
  }
1344
1384
  async function card(text, sub, ms = 2500) {
1385
+ MEME.fire('card', { text });
1345
1386
  const c = $('#card'); c.innerHTML = `<div><h1>${esc(text)}</h1><p>${esc(sub || '')}</p></div>`; c.classList.add('on'); AUD.tone(392, .5, 'triangle', .12); AUD.tone(523, .6, 'triangle', .12, .25);
1346
1387
  await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
1347
1388
  }
@@ -1373,7 +1414,7 @@ async function run(i) {
1373
1414
  if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1374
1415
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1375
1416
  if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; }
1376
- if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1417
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); MEME.fire('tool', { id: ev.agent }); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1377
1418
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1378
1419
  if (isSub(ev.agent) && ev.kind !== 'order') {
1379
1420
  while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
@@ -1386,13 +1427,14 @@ async function run(i) {
1386
1427
  }
1387
1428
  }
1388
1429
  if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1430
+ if (group.length >= 3) MEME.fire('combo', { ids: group.map((g) => g.agent) });
1389
1431
  prefetchQuips(i); warmTTS(i);
1390
1432
  const playOne = (g) => {
1391
1433
  applyStatus(g);
1392
1434
  const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = spoken(g);
1393
1435
  startTalk(g.agent, line, label);
1394
- if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1395
- if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1436
+ if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); MEME.fire('order', { from: g.agent, to: g.to }); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1437
+ if (g.kind === 'report') { AUD.ding(); MEME.fire('report', { from: g.agent, to: g.to }); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1396
1438
  return speakLine(clipSpeech(line, S.pace), S.agents[g.agent], true, token, FR() && trMem.has(baseText(g))).then(() => stopTalk(g.agent));
1397
1439
  };
1398
1440
  let plays; if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
@@ -1450,7 +1492,7 @@ async function fillProjects() { // live view: follow the latest activity anywh
1450
1492
  }
1451
1493
  $('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
1452
1494
  async function followCurrent() {
1453
- const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : ''))).json();
1495
+ const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : '') + (S.cur && !S.pin ? '&sproj=' + encodeURIComponent(S.cur.proj) + '&sid=' + encodeURIComponent(S.cur.id) : ''))).json();
1454
1496
  if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
1455
1497
  if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
1456
1498
  if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
@@ -1633,12 +1675,32 @@ $('#langSel').onchange = () => setLang($('#langSel').value, true);
1633
1675
  $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1634
1676
  $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1635
1677
  $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
1636
- $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1678
+ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify', 'meme'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'meme') { $('#memeBox').style.display = prefs.meme ? '' : 'none'; if (prefs.meme) { claimAudio(); AUD.init(); } MEME.apply(); } if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1637
1679
  if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
1638
1680
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1639
1681
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1640
1682
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1641
- $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); };
1683
+ $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
1684
+ // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1685
+ let kokoroTimer = null;
1686
+ async function kokoroUI() {
1687
+ const box = $('#kokoroBox'), on = prefs.vstyle === 'kokoro'; box.style.display = on ? '' : 'none'; if (!on) { clearInterval(kokoroTimer); kokoroTimer = null; return; }
1688
+ let st; try { st = await (await fetch('/api/kokoro')).json(); } catch { return; }
1689
+ const b = $('#kokoroInstall'), h = $('#kokoroHint'); b.style.display = st.installed ? 'none' : '';
1690
+ b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
1691
+ h.textContent = st.installing ? L('Téléchargement en cours, vous pouvez continuer : les voix du système servent en attendant.', 'Downloading, you can carry on: system voices are used meanwhile.')
1692
+ : st.error ? st.error : !st.npm && !st.installed ? L('npm est introuvable : installez Node.js pour activer cette option.', 'npm not found: install Node.js to enable this option.')
1693
+ : st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') : L('Chargement du modèle (téléchargé au premier usage, environ 155 Mo)…', 'Loading the model (downloaded on first use, about 155 MB)…'))
1694
+ : L('Voix locales de bonne qualité, en anglais surtout (une seule voix française). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.');
1695
+ if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
1696
+ if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
1697
+ }
1698
+ $('#kokoroInstall').onclick = async () => { try { await fetch('/api/kokoro/install', { method: 'POST' }); } catch {} kokoroUI(); };
1699
+ kokoroUI();
1700
+ const showMemeLvl = () => { const l = prefs.memeLevel ?? 11; $('#memeLvl').value = l; $('#memeLvlVal').textContent = l + ' / 11' + (l === 11 ? ' 🔥' : ''); }; showMemeLvl();
1701
+ $('#memeLvl').oninput = (e) => { prefs.memeLevel = +e.target.value; savePrefs(); showMemeLvl(); };
1702
+ $('#memeVol').value = prefs.memeVol ?? 0.8; $('#memeBox').style.display = prefs.meme ? '' : 'none';
1703
+ $('#memeVol').oninput = (e) => { prefs.memeVol = +e.target.value; savePrefs(); MEME.apply(); };
1642
1704
  $('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
1643
1705
  $('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1644
1706
  $('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
@@ -1666,13 +1728,14 @@ if (EMBED) $('#mBrowser').style.display = '';
1666
1728
 
1667
1729
  (async function init() {
1668
1730
  if (!LANGS.some((l) => l[0] === prefs.lang)) prefs.lang = 'en'; await loadLocale(prefs.lang); await loadChars();
1669
- document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); applyMute(); TTS.init().then(updateHint);
1731
+ document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); MEME.apply(); applyMute(); TTS.init().then(updateHint);
1670
1732
  loadHealth().then(() => { // French by default only if translation can actually work; otherwise stay in English without a warning
1671
1733
  const h = S.health || {};
1672
1734
  if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
1673
1735
  if (FR()) fetch('/api/warm').catch(() => {});
1674
1736
  });
1675
- if (Q.get('lang')) { await setLang(Q.get('lang'), false); } // not saved: lets a link force a language
1737
+ if (Q.get('lang')) { await setLang(Q.get('lang'), false); }
1738
+ if (Q.get('meme') === '1') { prefs.meme = true; $('#memeBox').style.display = ''; MEME.apply(); } // a link can force meme mode (demo links) // not saved: lets a link force a language
1676
1739
  if (Q.get('demo')) {
1677
1740
  if (Q.get('view') === 'tree') setView('tree');
1678
1741
  await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
@@ -165,5 +165,16 @@
165
165
  "Open folder": "Ordner öffnen",
166
166
  "custom character(s) loaded": "eigene Figur(en) geladen",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Leg deine Pixel-Art (PNG) in diesen Ordner, um die Figuren zu ersetzen:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund",
169
+ "🔥 Meme mode (unhinged)": "🔥 Meme-Modus (völlig überdreht)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "Wird heruntergeladen, du kannst weitermachen: bis dahin kommen die Systemstimmen zum Einsatz.",
171
+ "Effects volume": "Lautstärke der Effekte",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, vor allem Englisch (nur eine französische Stimme). Kostenlos, nichts verlässt deinen Rechner.",
173
+ "Installing…": "Installation läuft…",
174
+ "Intensity": "Intensität",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
177
+ "The engine stopped: system voices are used instead.": "Die Engine wurde beendet: stattdessen kommen die Systemstimmen zum Einsatz.",
178
+ "Very natural voices (installs on demand)": "Sehr natürliche Stimmen (werden bei Bedarf installiert)",
179
+ "Voices ready.": "Stimmen bereit."
169
180
  }
@@ -8,6 +8,7 @@
8
8
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ This dev was replaced by his agents — buy him a coffee",
9
9
  "🎞 Export video": "🎞 Export video",
10
10
  "📋 Recap": "📋 Recap",
11
+ "🔥 Meme mode (unhinged)": "🔥 Meme mode (unhinged)",
11
12
  "Agent voices": "Agent voices",
12
13
  "Agents": "Agents",
13
14
  "Agents list (left)": "Agents list (left)",
@@ -41,10 +42,12 @@
41
42
  "done": "done",
42
43
  "Done": "Done",
43
44
  "Done. ": "Done. ",
45
+ "Downloading, you can carry on: system voices are used meanwhile.": "Downloading, you can carry on: system voices are used meanwhile.",
44
46
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Drop your pixel art (PNG) in this folder to replace the characters:",
45
47
  "duration": "duration",
46
48
  "Editing ": "Editing ",
47
49
  "Editor": "Editor",
50
+ "Effects volume": "Effects volume",
48
51
  "EPISODE 1": "EPISODE 1",
49
52
  "Episode recap": "Episode recap",
50
53
  "errors": "errors",
@@ -56,10 +59,13 @@
56
59
  "Files touched": "Files touched",
57
60
  "Final report": "Final report",
58
61
  "first error": "first error",
62
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.",
59
63
  "Highlights": "Highlights",
60
64
  "Human": "Human",
61
65
  "idle": "idle",
62
66
  "input": "input",
67
+ "Installing…": "Installing…",
68
+ "Intensity": "Intensity",
63
69
  "is sharing their screen": "is sharing their screen",
64
70
  "Join the call": "Join the call",
65
71
  "Language": "Language",
@@ -70,6 +76,7 @@
70
76
  "lines": "lines",
71
77
  "Listing ": "Listing ",
72
78
  "Little lines & animations": "Little lines & animations",
79
+ "Loading the model (downloaded on first use, about 155 MB)…": "Loading the model (downloaded on first use, about 155 MB)…",
73
80
  "longest wait ": "longest wait ",
74
81
  "Looking for a tool": "Looking for a tool",
75
82
  "looping?": "looping?",
@@ -84,6 +91,7 @@
84
91
  "No recent session": "No recent session",
85
92
  "No system voice found: agents will mumble.": "No system voice found: agents will mumble.",
86
93
  "Notify me when agents finish": "Notify me when agents finish",
94
+ "npm not found: install Node.js to enable this option.": "npm not found: install Node.js to enable this option.",
87
95
  "Office ambience (keys, coffee…)": "Office ambience (keys, coffee…)",
88
96
  "Office music (FIP radio)": "Office music (FIP radio)",
89
97
  "OFFLINE": "OFFLINE",
@@ -134,6 +142,7 @@
134
142
  "task": "task",
135
143
  "Tasks": "Tasks",
136
144
  "THE END": "THE END",
145
+ "The engine stopped: system voices are used instead.": "The engine stopped: system voices are used instead.",
137
146
  "This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "This dev was replaced by his own agents. Spare a coffee for his career change? ☕",
138
147
  "Time": "Time",
139
148
  "to": "to",
@@ -149,6 +158,8 @@
149
158
  "Untitled session": "Untitled session",
150
159
  "Updating todo list": "Updating todo list",
151
160
  "Using skill ": "Using skill ",
161
+ "Very natural voices (installs on demand)": "Very natural voices (installs on demand)",
162
+ "Voices ready.": "Voices ready.",
152
163
  "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.": "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.",
153
164
  "waiting": "waiting",
154
165
  "WAITING": "WAITING",
@@ -165,5 +165,16 @@
165
165
  "Open folder": "Abrir carpeta",
166
166
  "custom character(s) loaded": "personaje(s) propio(s) cargado(s)",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Suelta tu pixel art (PNG) en esta carpeta para reemplazar los personajes:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = el agente principal · you.png = tú · el resto = los subagentes · name-talk.png = boca abierta"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = el agente principal · you.png = tú · el resto = los subagentes · name-talk.png = boca abierta",
169
+ "🔥 Meme mode (unhinged)": "🔥 Modo meme (desmadre total)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "Descargando, puedes seguir: mientras tanto se usan las voces del sistema.",
171
+ "Effects volume": "Volumen de los efectos",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Voces locales de buena calidad, sobre todo en inglés (una sola voz en francés). Gratis, nada sale de tu equipo.",
173
+ "Installing…": "Instalando…",
174
+ "Intensity": "Intensidad",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "Cargando el modelo (se descarga la primera vez, unos 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "No se encontró npm: instala Node.js para activar esta opción.",
177
+ "The engine stopped: system voices are used instead.": "El motor se detuvo: se usan las voces del sistema.",
178
+ "Very natural voices (installs on demand)": "Voces muy naturales (se instalan bajo demanda)",
179
+ "Voices ready.": "Voces listas."
169
180
  }
@@ -165,5 +165,16 @@
165
165
  "Open folder": "フォルダを開く",
166
166
  "custom character(s) loaded": "件のカスタムキャラクターを読み込みました",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "このフォルダにドット絵(PNG)を入れると、キャラクターを差し替えられます:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = メインエージェント · you.png = あなた · それ以外 = サブエージェント · name-talk.png = 口を開けた状態"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = メインエージェント · you.png = あなた · それ以外 = サブエージェント · name-talk.png = 口を開けた状態",
169
+ "🔥 Meme mode (unhinged)": "🔥 ミームモード(やりすぎ)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "ダウンロード中です。そのまま作業を続けられます。それまではシステム音声を使います。",
171
+ "Effects volume": "効果音の音量",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質の良いローカル音声(主に英語、フランス語は1種類のみ)。無料で、データがPCの外に出ることはありません。",
173
+ "Installing…": "インストール中…",
174
+ "Intensity": "強度",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "npm が見つかりません。このオプションを使うには Node.js をインストールしてください。",
177
+ "The engine stopped: system voices are used instead.": "エンジンが停止しました。代わりにシステム音声を使います。",
178
+ "Very natural voices (installs on demand)": "とても自然な音声(必要時にインストール)",
179
+ "Voices ready.": "音声の準備ができました。"
169
180
  }
@@ -1,5 +1,9 @@
1
1
  {
2
- "_meta": {"language": "pt-BR", "reviewed": false, "note": "AI draft, native review welcome"},
2
+ "_meta": {
3
+ "language": "pt-BR",
4
+ "reviewed": false,
5
+ "note": "AI draft, native review welcome"
6
+ },
3
7
  "Characters": "Personagens",
4
8
  "Reload": "Recarregar",
5
9
  "Open folder": "Abrir pasta",
@@ -161,5 +165,16 @@
161
165
  "Writing ": "Escrevendo ",
162
166
  "You": "Você",
163
167
  "Your agents are done 🎉": "Seus agentes terminaram 🎉",
164
- "your turn": "sua vez"
168
+ "your turn": "sua vez",
169
+ "🔥 Meme mode (unhinged)": "🔥 Modo meme (sem noção)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
171
+ "Effects volume": "Volume dos efeitos",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
173
+ "Installing…": "Instalando…",
174
+ "Intensity": "Intensidade",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
177
+ "The engine stopped: system voices are used instead.": "O mecanismo parou: as vozes do sistema são usadas no lugar.",
178
+ "Very natural voices (installs on demand)": "Vozes muito naturais (instaladas sob demanda)",
179
+ "Voices ready.": "Vozes prontas."
165
180
  }
@@ -165,5 +165,16 @@
165
165
  "Open folder": "打开文件夹",
166
166
  "custom character(s) loaded": "个自定义角色已加载",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素画(PNG)放进此文件夹即可替换角色:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴",
169
+ "🔥 Meme mode (unhinged)": "🔥 梗图模式(彻底放飞)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
171
+ "Effects volume": "音效音量",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
173
+ "Installing…": "正在安装…",
174
+ "Intensity": "强度",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "正在加载模型(首次使用时下载,约 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
177
+ "The engine stopped: system voices are used instead.": "引擎已停止:改用系统语音。",
178
+ "Very natural voices (installs on demand)": "超自然语音(按需安装)",
179
+ "Voices ready.": "语音已就绪。"
169
180
  }
@@ -18,8 +18,8 @@
18
18
  "Agents list (left)": "代理清單(左側)",
19
19
  "All projects (most recent)": "所有專案(最近優先)",
20
20
  "Ambience volume": "環境音量",
21
- "An agent may be waiting for you ✋": "有代理可能在等你 ✋",
22
- "Animal Crossing (babble)": "Animal Crossing(咿呀語)",
21
+ "An agent may be waiting for you ✋": "可能有代理在等你 ✋",
22
+ "Animal Crossing (babble)": "動物森友會說話聲",
23
23
  "Asking a question": "正在提問",
24
24
  "brief": "簡述",
25
25
  "Busiest agents": "最忙碌的代理",
@@ -79,7 +79,7 @@
79
79
  "Little lines & animations": "小對白與動畫",
80
80
  "longest wait ": "最長等待 ",
81
81
  "Looking for a tool": "正在找工具",
82
- "looping?": "原地打轉?",
82
+ "looping?": "鬼打牆?",
83
83
  "Music volume": "音樂音量",
84
84
  "Natural voices": "自然語音",
85
85
  "need you": "需要你",
@@ -101,7 +101,7 @@
101
101
  "Project to follow": "要追蹤的專案",
102
102
  "Prompt from": "提示來自",
103
103
  "Radio unreachable: check your connection or pick another station.": "無法連上電台:請檢查網路連線或換一個電台。",
104
- "Reactions": "回應",
104
+ "Reactions": "表情回應",
105
105
  "Reactions & jokes": "回應與玩笑",
106
106
  "Reactions bar (👍 👏 ☕)": "回應列(👍 👏 ☕)",
107
107
  "read-only": "唯讀",
@@ -124,7 +124,7 @@
124
124
  "session": "工作階段",
125
125
  "Sessions": "工作階段",
126
126
  "Settings": "設定",
127
- "Simlish (babble)": "Simlish(咿呀語)",
127
+ "Simlish (babble)": "模擬市民說話聲",
128
128
  "Sound": "聲音",
129
129
  "Sound (M key)": "聲音(M 鍵)",
130
130
  "Sound is playing in another Agent Standup window. Click 🔈 to take it over here.": "聲音正在另一個 Agent Standup 視窗播放。點擊 🔈 即可改在此處播放。",
@@ -165,5 +165,16 @@
165
165
  "Writing ": "撰寫 ",
166
166
  "You": "你",
167
167
  "Your agents are done 🎉": "你的代理都完成了 🎉",
168
- "your turn": "換你了"
168
+ "your turn": "換你了",
169
+ "🔥 Meme mode (unhinged)": "🔥 迷因模式(完全失控)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
171
+ "Effects volume": "音效音量",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
173
+ "Installing…": "安裝中…",
174
+ "Intensity": "強度",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "正在載入模型(第一次使用時下載,約 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
177
+ "The engine stopped: system voices are used instead.": "引擎已停止:改用系統語音。",
178
+ "Very natural voices (installs on demand)": "超自然語音(需要時才安裝)",
179
+ "Voices ready.": "語音已就緒。"
169
180
  }
@@ -0,0 +1,60 @@
1
+ /* Meme mode (public/meme.js). Everything hangs off body.meme or lives in #memeLayer, so this file is inert while the mode is off. */
2
+ #memeLayer { position:absolute; inset:0; z-index:12; pointer-events:none; overflow:hidden; display:none }
3
+ body.meme #memeLayer { display:block }
4
+ .mm { position:absolute; font-family:Impact,"Arial Black",Anton,"Helvetica Neue",sans-serif; will-change:transform,opacity }
5
+
6
+ /* Impact text slamming in, then drifting off */
7
+ .mm.slam { transform:translate(-50%,-50%); text-transform:uppercase; font-weight:900; letter-spacing:.5px; line-height:1; white-space:nowrap; max-width:92%; overflow:hidden; text-overflow:ellipsis; -webkit-text-stroke:2px #000; paint-order:stroke fill; text-shadow:4px 4px 0 #000, -2px -2px 0 #000, 0 0 18px rgba(0,0,0,.6); animation:mmSlam 1.4s cubic-bezier(.2,1.6,.4,1) forwards }
8
+ @keyframes mmSlam { 0% { transform:translate(-50%,-50%) rotate(var(--rot,0deg)) scale(4); opacity:0; filter:blur(6px) } 18% { transform:translate(-50%,-50%) rotate(var(--rot,0deg)) scale(1); opacity:1; filter:none } 80% { transform:translate(-50%,-50%) rotate(var(--rot,0deg)) scale(1.08); opacity:1 } 100% { transform:translate(-50%,-50%) rotate(var(--rot,0deg)) scale(1.6); opacity:0 } }
9
+
10
+ /* top text / bottom text */
11
+ .mm.tb { inset:0; display:flex; flex-direction:column; justify-content:space-between; align-items:center; padding:3% 4%; text-align:center; animation:mmFade 2.2s forwards }
12
+ .mm.tb div { font-size:clamp(28px,6vw,72px); line-height:1; color:#fff; text-transform:uppercase; -webkit-text-stroke:2px #000; paint-order:stroke fill; text-shadow:3px 3px 0 #000; animation:mmPunch .5s cubic-bezier(.2,1.6,.4,1) backwards }
13
+ .mm.tb .bot { animation-delay:.25s }
14
+ @keyframes mmPunch { from { transform:scale(3); opacity:0 } }
15
+ @keyframes mmFade { 0%, 80% { opacity:1 } 100% { opacity:0 } }
16
+
17
+ /* lens flare: a spinning rainbow disc, screen-blended */
18
+ .mm.flare { transform:translate(-50%,-50%); border-radius:50%; mix-blend-mode:screen; opacity:.9; background:conic-gradient(from 0deg,#ff0040,#ff8a00,#ffe600,#3bff6e,#52d8ff,#8a5cff,#ff0040); -webkit-mask:radial-gradient(circle,#000 0,rgba(0,0,0,.6) 25%,transparent 70%); mask:radial-gradient(circle,#000 0,rgba(0,0,0,.6) 25%,transparent 70%); animation:mmFlare 1.6s ease-out forwards }
19
+ @keyframes mmFlare { 0% { transform:translate(-50%,-50%) rotate(0) scale(.2); opacity:0 } 25% { opacity:.95 } 100% { transform:translate(-50%,-50%) rotate(540deg) scale(1.4); opacity:0 } }
20
+
21
+ /* hitmarker */
22
+ .mm.hit { transform:translate(-50%,-50%); color:#fff; font-size:42px; line-height:1; text-shadow:0 0 4px #000, 2px 2px 0 #000; animation:mmHit .7s ease-out forwards }
23
+ @keyframes mmHit { 0% { transform:translate(-50%,-50%) scale(2.2); opacity:1 } 40% { transform:translate(-50%,-50%) scale(1) } 100% { transform:translate(-50%,-50%) scale(.9); opacity:0 } }
24
+
25
+ /* emoji rain, colour flash, star wipe */
26
+ .mm.rain { top:-50px; line-height:1; filter:drop-shadow(0 3px 4px rgba(0,0,0,.5)); animation:mmRain 2.4s ease-in forwards }
27
+ @keyframes mmRain { to { transform:translateY(120vh) rotate(720deg) } }
28
+ .mm.flash { inset:0; animation:mmFlash .11s ease-out forwards }
29
+ @keyframes mmFlash { from { opacity:1 } to { opacity:0 } }
30
+ .mm.wipe { inset:-20%; background:#fff }
31
+ .mm.wipe.star { clip-path:polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%); background:conic-gradient(#ff0040,#ffe600,#3bff6e,#52d8ff,#ff5cf0,#ff0040); animation:mmStar 1.3s ease-in-out forwards }
32
+ @keyframes mmStar { 0% { transform:scale(0) rotate(0) } 45% { transform:scale(3) rotate(180deg) } 55% { transform:scale(3) rotate(200deg) } 100% { transform:scale(0) rotate(360deg) } }
33
+
34
+ /* deal with it, Illuminati, to be continued */
35
+ .mm.shades { transform:translate(-50%,0); line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.6)); animation:mmShades .6s cubic-bezier(.3,1.5,.5,1) forwards }
36
+ @keyframes mmShades { from { transform:translate(-50%,-120px); opacity:0 } to { transform:translate(-50%,var(--fall,40px)); opacity:1 } }
37
+ .mm.tri { left:50%; top:45%; width:220px; height:190px; margin:-95px 0 0 -110px; animation:mmTri 2.6s ease-in-out forwards }
38
+ .mm.tri i { display:block; width:0; height:0; border-left:110px solid transparent; border-right:110px solid transparent; border-bottom:190px solid #3bff6e; filter:drop-shadow(0 0 24px #3bff6e) }
39
+ .mm.tri span { position:absolute; left:50%; top:110px; transform:translate(-50%,-50%); font-size:60px; line-height:1 }
40
+ @keyframes mmTri { 0% { transform:scale(0) rotate(-180deg); opacity:0 } 30% { transform:scale(1) rotate(0); opacity:1 } 85% { transform:scale(1.1) rotate(20deg); opacity:1 } 100% { transform:scale(3) rotate(40deg); opacity:0 } }
41
+ #center.mm-freeze #stage, #center.mm-freeze #present, #center.mm-freeze #roster { filter:sepia(1) contrast(1.2) saturate(.6) }
42
+ .mm.tbc { left:4%; bottom:9%; font-size:clamp(26px,4vw,48px); line-height:1; font-style:italic; color:#fff; -webkit-text-stroke:1px #000; paint-order:stroke fill; text-shadow:3px 3px 0 #000; animation:mmTbc .5s ease-out }
43
+ @keyframes mmTbc { from { transform:translateX(-120%) } }
44
+
45
+ /* the tiles: punch-in zoom with chromatic aberration, deep-fried pulses */
46
+ body.meme .tile.mm-zoom { animation:mmZoom .5s cubic-bezier(.2,1.5,.4,1); z-index:3 }
47
+ @keyframes mmZoom { 0% { transform:scale(1) } 30% { transform:scale(1.18) rotate(-1.5deg); filter:drop-shadow(-4px 0 0 rgba(255,0,0,.7)) drop-shadow(4px 0 0 rgba(0,180,255,.7)) } 100% { transform:none } }
48
+ body.meme .tile.mm-fry { animation:mmFry .4s steps(2) infinite }
49
+ @keyframes mmFry { 0%, 100% { filter:contrast(1.7) saturate(2.6) brightness(1.1) } 50% { filter:contrast(2.2) saturate(4) brightness(1.2) hue-rotate(15deg) } }
50
+
51
+ /* the whole call in costume: Impact subtitles, a spinning logo, a rainbow glow round the stage */
52
+ body.meme .bubble, body.meme #pcap { font-family:Impact,"Arial Black",Anton,sans-serif; text-transform:uppercase; letter-spacing:.5px; -webkit-text-stroke:1px #000; paint-order:stroke fill; text-shadow:2px 2px 0 #000; font-size:15px }
53
+ body.meme .tile.focus .bubble { font-size:20px }
54
+ body.meme .brand .logo { animation:mmSpin 2s linear infinite }
55
+ @keyframes mmSpin { to { transform:rotate(360deg) } }
56
+ body.meme #center::after { content:""; position:absolute; inset:0; pointer-events:none; z-index:11; animation:mmGlow 4s linear infinite }
57
+ @keyframes mmGlow { 0% { box-shadow:inset 0 0 50px rgba(255,0,64,.45) } 25% { box-shadow:inset 0 0 50px rgba(255,230,0,.45) } 50% { box-shadow:inset 0 0 50px rgba(59,255,110,.45) } 75% { box-shadow:inset 0 0 50px rgba(82,216,255,.45) } 100% { box-shadow:inset 0 0 50px rgba(255,0,64,.45) } }
58
+
59
+ /* The switch is an explicit ask for motion, so reduced motion only removes the full-screen flashes and wipes (strobing). */
60
+ @media (prefers-reduced-motion:reduce) { .mm.flash, .mm.wipe { display:none } }
package/public/meme.js ADDED
@@ -0,0 +1,235 @@
1
+ // Meme mode: an optional costume over the call.
2
+ // Airhorns, hitmarkers, lens flares, Impact captions, vine booms, sad trombones, star wipes, emoji rain. It never changes
3
+ // what is shown or said, only how loudly. Off by default (⚙ → 🔥 Meme mode), intensity 1–11.
4
+ //
5
+ // Loaded before the page's own script. It reads that script's globals (prefs, S, AUD) at call time only: classic scripts
6
+ // share one global lexical scope, and nothing here runs before the page has started playing. The pure parts (CORE) have no DOM
7
+ // and also run under `node --test` (test/meme.test.js).
8
+ (function (root) {
9
+ 'use strict';
10
+
11
+ // ---- pure parts: word picking, intensity curve, flash guard, deterministic choice
12
+ const CORE = {
13
+ hash(s) { let h = 2166136261; for (const c of String(s)) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; },
14
+ pick(list, seed) { return list[(seed >>> 0) % list.length]; },
15
+ // The loud words of a line, for the kinetic captions: words already in capitals first, then the longest; no duplicates.
16
+ words(text, max = 3) {
17
+ const seen = new Set(), out = [];
18
+ const ws = String(text || '').split(/[^\p{L}\p{N}'’]+/u).map((w) => w.replace(/^['’]+|['’]+$/g, '')).filter((w) => w.length >= 3 && /\p{L}/u.test(w));
19
+ const score = (w) => (w === w.toUpperCase() ? 100 : 0) + w.length;
20
+ for (const w of ws.sort((a, b) => score(b) - score(a))) { const k = w.toLowerCase(); if (seen.has(k) || out.length >= max) continue; seen.add(k); out.push(w.toUpperCase()); }
21
+ return out;
22
+ },
23
+ // Intensity 1–11 → how often the optional extras fire, how long each effect rests, how hard the screen shakes, how much rains.
24
+ scale(level) {
25
+ const n = Math.round(+level), l = Number.isFinite(n) ? Math.max(1, Math.min(11, n)) : 11, t = (l - 1) / 10;
26
+ return { level: l, chance: +(0.2 + 0.8 * t).toFixed(3), cooldown: Math.round(6000 - 5400 * t), shake: +(2 + 16 * t).toFixed(1), particles: Math.round(6 + 34 * t) };
27
+ },
28
+ // Full-screen flashes are capped whatever the intensity: at most `max` in any `win` ms.
29
+ FlashGuard: class {
30
+ constructor(max = 3, win = 1000, now = () => Date.now()) { this.max = max; this.win = win; this.now = now; this.times = []; }
31
+ ok() { const t = this.now(); this.times = this.times.filter((x) => t - x < this.win); if (this.times.length >= this.max) return false; this.times.push(t); return true; }
32
+ },
33
+ };
34
+
35
+ // ---- the mode itself: sounds (WebAudio synth + a few bundled clips) and visuals (#memeLayer over the stage)
36
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
37
+ const T = (fr, en) => (typeof prefs !== 'undefined' && prefs.lang === 'fr' ? fr : en);
38
+ const nameOf = (id) => (((typeof S !== 'undefined' && S.agents) || {})[id] || {}).name || id;
39
+
40
+ const MEME = {
41
+ CORE, on: false, bus: null, nb: null, clips: new Map(), guard: new CORE.FlashGuard(), last: {}, timer: 0, rm: false,
42
+ CLIPS: { omg: '/sfx/oh-my-god.mp3', scratch1: '/sfx/scratch-1.mp3', scratch2: '/sfx/scratch-2.mp3', scratch3: '/sfx/scratch-3.mp3' },
43
+ PAL: ['#fff', '#ffe600', '#ff3b3b', '#3bff6e', '#52d8ff', '#ff5cf0'],
44
+ WINS: () => ['GG', 'W', 'EZ', 'POGGERS', 'NO SCOPE', 'OH BABY', 'BASED', 'SHEEEESH', 'ABSOLUTE UNIT', 'LET HIM COOK'].concat(T(['BIEN OUEJ', 'C’EST PAS FAUX', 'OKLM', 'MAIS OUI C’EST CLAIR'], [])),
45
+ FAILS: () => ['BRUH', 'TASK FAILED SUCCESSFULLY', 'OH NO', 'EMOTIONAL DAMAGE', 'SKILL ISSUE', 'IT’S OVER', 'RIP', 'F IN THE CHAT', 'WHO DID THIS', 'WORKS ON MY MACHINE'].concat(T(['OH LE BOULET', 'C’EST LA CATA', 'MAIS NON'], [])),
46
+ STALLS: () => ['👁👄👁', T('tout va bien 🔥🐶', 'this is fine 🔥🐶'), 'hello?? 📞', T('⏳ ça vient ça vient', '⏳ any day now'), T('il est en train de CUISINER 🧑‍🍳', 'bro is COOKING 🧑‍🍳'), 'AFK?', T('*grillons*', '*crickets*')],
47
+ COMBOS: ['WOMBO COMBO', 'OH BABY A TRIPLE', 'TRIPLE KILL', 'HOLY MOLY', 'MULTI-TRACK DRIFTING'],
48
+
49
+ // ---------- lifecycle ----------
50
+ active() { return !!(this.on && typeof S !== 'undefined' && S.playing && !S.recomputing && !document.hidden); },
51
+ sound() { return !!(this.on && this.bus && typeof AUD !== 'undefined' && AUD.ctx && !AUD.off()); },
52
+ cfg() { return CORE.scale(typeof prefs !== 'undefined' ? prefs.memeLevel : 11); },
53
+ roll(k = 1) { return Math.random() < this.cfg().chance * k; },
54
+ rest(key, mul = 1) { const now = performance.now(); if (now < (this.last[key] || 0)) return false; this.last[key] = now + this.cfg().cooldown * mul; return true; },
55
+ apply() { // called when the switch, the mute state or the audio ownership changes, and once at start-up
56
+ this.on = !!(typeof prefs !== 'undefined' && prefs.meme);
57
+ document.body.classList.toggle('meme', this.on);
58
+ this.rm = !!(root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches); // the switch asks for motion; reduced motion only turns off flashes and wipes
59
+ this.ensureBus(); if (this.bus) this.bus.gain.value = this.sound() ? (prefs.memeVol ?? 0.8) : 0;
60
+ if (this.on && !this.timer) this.timer = setInterval(() => this.tick(), 4000);
61
+ if (!this.on) { clearInterval(this.timer); this.timer = 0; const l = document.getElementById('memeLayer'); if (l) l.innerHTML = ''; document.querySelectorAll('.mm-fry, .mm-zoom').forEach((t) => t.classList.remove('mm-fry', 'mm-zoom')); }
62
+ },
63
+ ensureBus() { if (this.bus || typeof AUD === 'undefined' || !AUD.ctx) return; const c = AUD.ctx; this.bus = c.createGain(); this.bus.connect(c.destination); if (AUD.rec) this.bus.connect(AUD.rec); }, // into the export mix too
64
+ fire(kind, info = {}) { if (!this.active()) return; try { this.ensureBus(); const h = this.ON[kind]; if (h) h.call(this, info); } catch {} },
65
+ tick() { // stalled agents (the tiles the page already marks .slow / .quiet) get crickets
66
+ if (!this.active()) return; const stuck = [...document.querySelectorAll('#stage .tile.quiet, #stage .tile.slow')].filter((t) => t.style.display !== 'none');
67
+ if (!stuck.length || !this.roll(0.5) || !this.rest('stall', 4)) return;
68
+ const t = stuck[Math.floor(Math.random() * stuck.length)], id = t.id.slice(2), r = this.rect(id); this.crickets();
69
+ this.slam(CORE.pick(this.STALLS(), CORE.hash(id + ((Date.now() / 10000) | 0))), { size: 30, left: r && r.cx + 'px', top: r && r.y + 'px', ms: 2500 });
70
+ },
71
+
72
+ // ---------- what each event does ----------
73
+ ON: {
74
+ talk(i) { if (i.id === 'user') return this.ON.user.call(this, i); if (!this.rest('talk', 0.5)) return; this.zoom(i.id); if (this.roll()) this.kinetic(i.text); if (this.cfg().level >= 9 && this.roll(0.6)) this.fry(i.id); },
75
+ interrupt() { this.scratched = performance.now(); this.clip('scratch' + (1 + Math.floor(Math.random() * 3)), () => this.scratch()); }, // the human cut a line being read: a real record scratch
76
+ user() { if (!this.rest('user', 2)) return; if (performance.now() - (this.scratched || 0) > 2500) this.scratch(); this.topBottom(T('L’HUMAIN EST', 'THE HUMAN HAS'), T('ENTRÉ DANS LE CHAT', 'ENTERED THE CHAT')); this.flash('rgba(255,255,255,.5)'); if (this.roll()) this.rain(['👀', '😳', '🫡'], Math.round(this.cfg().particles / 2)); },
77
+ order(i) {
78
+ if (!this.rest('order')) return; this.airhorn(1); const r = this.rect(i.to); if (r) this.flare(r.cx, r.cy, Math.max(200, r.w * 1.2));
79
+ this.slam(T(`${nameOf(i.to)} DÉPLOYÉ`, `DEPLOYING ${nameOf(i.to)}`), { size: 56, color: '#ffe600' }); if (this.roll(0.5)) this.flash('rgba(255,230,0,.35)');
80
+ },
81
+ report(i) {
82
+ if (!this.rest('report')) return; this.hitmarker(); this.sparkle(); const r = this.rect(i.from);
83
+ if (r) for (let k = 0; k < 3; k++) setTimeout(() => this.cross(r.x + Math.random() * r.w, r.y + Math.random() * r.h), k * 90);
84
+ const xp = 100 * (1 + Math.floor(Math.random() * 9)); this.slam(`+${xp} XP`, { size: 40, color: '#3bff6e', left: r && r.cx + 'px', top: r && r.y + 'px' });
85
+ if (this.roll(0.7)) setTimeout(() => this.active() && this.slam(CORE.pick(this.WINS(), CORE.hash(i.from + xp)), { size: 64 }), 250);
86
+ if (this.roll(0.45)) { this.airhorn(3); this.flash('rgba(255,255,255,.35)'); }
87
+ if (this.roll(0.5)) setTimeout(() => this.active() && this.slam('STONKS 📈', { size: 44, color: '#52d8ff', top: '8%' }), 600);
88
+ },
89
+ tool(i) {
90
+ if (!this.roll(0.08) || !this.rest('tool', 0.3)) return; this.hitmarker(); const r = this.rect(i.id); if (!r) return;
91
+ this.cross(r.cx + (Math.random() - 0.5) * r.w * 0.6, r.cy + (Math.random() - 0.5) * r.h * 0.6); this.slam('+10', { size: 22, left: r.cx + 'px', top: r.cy + 'px', ms: 800 });
92
+ },
93
+ fail(i) {
94
+ if (!this.rest('fail', 1.5)) return; this.shake(1.4); this.flash('rgba(255,0,0,.45)');
95
+ if (Math.random() < 0.4) this.clip('omg', () => this.boom()); else { this.boom(); setTimeout(() => this.active() && (Math.random() < 0.5 ? this.trombone() : this.bruh()), 450); }
96
+ this.slam(CORE.pick(this.FAILS(), CORE.hash(i.id + Date.now())), { size: 70, color: '#ff3b3b' });
97
+ if (this.roll()) this.rain(['💀', '🤡', '🔥', '😭'], Math.round(this.cfg().particles / 2)); const r = this.rect(i.id); if (r && this.roll(0.5)) this.slam('💀', { size: 60, left: r.cx + 'px', top: r.cy + 'px' });
98
+ },
99
+ leave(i) {
100
+ if (!this.rest('leave')) return;
101
+ if (Math.random() < 0.5) { this.yeet(); this.fling(i.id); const r = this.rect(i.id); this.slam('YEET', { size: 64, color: '#ffe600', left: r && r.cx + 'px', top: r && r.cy + 'px' }); } else { this.sting(); this.freeze(); }
102
+ },
103
+ win() {
104
+ if (!this.rest('win', 4)) return; this.airhorn(3); setTimeout(() => this.active() && this.badumtss(), 1300); setTimeout(() => this.active() && this.sparkle(), 1800);
105
+ this.rain(['🌮', '🥤', '🔥', '💯', '😂', '🏆']); const l = this.layer().getBoundingClientRect(); for (let k = 0; k < 4; k++) setTimeout(() => this.flare(Math.random() * l.width, Math.random() * l.height, 220 + Math.random() * 220), k * 250);
106
+ this.triangle(); this.slam(T('MAMAN VIENS VOIR', 'MOM GET THE CAMERA'), { size: 64, color: '#ffe600', top: '20%' }); setTimeout(() => this.shades('main'), 900); this.flash('rgba(255,255,255,.5)'); if (this.roll(0.6)) this.spin();
107
+ },
108
+ card() { if (!this.rest('card', 2)) return; this.sting(); this.wipe('star'); },
109
+ combo(i) {
110
+ if (!this.rest('combo', 3)) return; this.wub(); setTimeout(() => this.active() && this.ohh(), 300); this.shake(0.8);
111
+ this.slam(CORE.pick(this.COMBOS, CORE.hash(String(Date.now()))), { size: 80, color: '#ff5cf0', top: '30%' });
112
+ (i.ids || []).forEach((id, k) => { const r = this.rect(id); if (r) setTimeout(() => this.flare(r.cx, r.cy, r.w * 1.3), k * 150); }); if (this.roll(0.6)) this.spin();
113
+ },
114
+ },
115
+
116
+ // ---------- sounds: synth patches on the meme bus, plus bundled clips ----------
117
+ node(type, f) { const o = AUD.ctx.createOscillator(); o.type = type; o.frequency.value = f; return o; },
118
+ env(t, a, h, r, v) { const g = AUD.ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(v, t + a); g.gain.setValueAtTime(v, t + a + h); g.gain.exponentialRampToValueAtTime(0.0001, t + a + h + r); return g; },
119
+ dist(k = 40) { const ws = AUD.ctx.createWaveShaper(), n = 256, curve = new Float32Array(n); for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curve[i] = ((3 + k) * x * 20 * (Math.PI / 180)) / (Math.PI + k * Math.abs(x)); } ws.curve = curve; return ws; },
120
+ noise() { const c = AUD.ctx; if (!this.nb) { this.nb = c.createBuffer(1, c.sampleRate * 2, c.sampleRate); const d = this.nb.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; } const s = c.createBufferSource(); s.buffer = this.nb; s.loop = true; return s; },
121
+ filt(type, f, q = 1) { const b = AUD.ctx.createBiquadFilter(); b.type = type; b.frequency.value = f; b.Q.value = q; return b; },
122
+ airhorn(n = 1) { // three detuned saws and a sub square, clipped hard, pitch sliding up into the note; the last of three holds
123
+ if (!this.sound()) return; const c = AUD.ctx;
124
+ for (let k = 0; k < n; k++) {
125
+ const t = c.currentTime + k * 0.32, len = n > 1 && k === n - 1 ? 0.85 : 0.28, g = this.env(t, 0.015, len, 0.15, 0.35), d = this.dist(50), lp = this.filt('lowpass', 2600, 2);
126
+ [[466, 'sawtooth'], [468.5, 'sawtooth'], [463.5, 'sawtooth'], [233, 'square']].forEach(([f, ty]) => { const o = this.node(ty, f * 0.88); o.frequency.exponentialRampToValueAtTime(f, t + 0.06); o.connect(d); o.start(t); o.stop(t + len + 0.2); });
127
+ d.connect(lp).connect(g).connect(this.bus);
128
+ }
129
+ },
130
+ boom() { // vine boom: a sine dropping from 120 to 32 Hz with a thud of low noise
131
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, o = this.node('sine', 120), g = this.env(t, 0.005, 0.05, 1.3, 0.9); o.frequency.exponentialRampToValueAtTime(32, t + 0.5); o.connect(this.dist(8)).connect(g).connect(this.bus); o.start(t); o.stop(t + 1.5);
132
+ const n = this.noise(), ng = this.env(t, 0.003, 0.03, 0.25, 0.5); n.connect(this.filt('lowpass', 220)).connect(ng).connect(this.bus); n.start(t); n.stop(t + 0.4);
133
+ },
134
+ hitmarker() { // two clicks of bright noise and a tink
135
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime;
136
+ for (let k = 0; k < 2; k++) { const at = t + k * 0.045, n = this.noise(), g = this.env(at, 0.002, 0.01, 0.05, 0.35); n.connect(this.filt('highpass', 3800)).connect(g).connect(this.bus); n.start(at); n.stop(at + 0.08); }
137
+ const o = this.node('sine', 2650), g = this.env(t, 0.002, 0.02, 0.07, 0.18); o.connect(g).connect(this.bus); o.start(t); o.stop(t + 0.12);
138
+ },
139
+ trombone() { // sad trombone: four notes, each sliding down with a closing wah
140
+ if (!this.sound()) return; const c = AUD.ctx; let t = c.currentTime;
141
+ [[233, 0.38], [220, 0.38], [208, 0.38], [196, 1.0]].forEach(([f, d]) => {
142
+ const o = this.node('sawtooth', f), bp = this.filt('bandpass', 900, 2.5); bp.frequency.exponentialRampToValueAtTime(380, t + d); o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 0.94, t + d);
143
+ const g = this.env(t, 0.04, d - 0.12, 0.1, 0.3); o.connect(bp).connect(g).connect(this.bus); o.start(t); o.stop(t + d + 0.1); t += d + 0.05;
144
+ });
145
+ },
146
+ bruh() { // one low vowel: two saws through a formant sweep, pitch sagging
147
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, bp = this.filt('bandpass', 350, 3); bp.frequency.linearRampToValueAtTime(950, t + 0.12); bp.frequency.linearRampToValueAtTime(260, t + 0.5);
148
+ [115, 116.5].forEach((f) => { const o = this.node('sawtooth', f); o.frequency.linearRampToValueAtTime(f * 0.74, t + 0.55); o.connect(bp); o.start(t); o.stop(t + 0.65); });
149
+ bp.connect(this.env(t, 0.03, 0.35, 0.2, 0.45)).connect(this.bus);
150
+ },
151
+ sting() { // dramatic "dun dun dunnn": three minor stabs, the last one held
152
+ if (!this.sound()) return; const c = AUD.ctx, t0 = c.currentTime;
153
+ [[0, 0.32, [196, 233, 294]], [0.42, 0.32, [185, 220, 277]], [0.84, 1.6, [147, 175, 220]]].forEach(([at, d, fs]) => {
154
+ const t = t0 + at, lp = this.filt('lowpass', 1200), g = this.env(t, 0.02, d, 0.3, 0.22);
155
+ fs.forEach((f) => [this.node('sawtooth', f), this.node('square', f / 2)].forEach((o) => { o.connect(lp); o.start(t); o.stop(t + d + 0.4); })); lp.connect(g).connect(this.bus);
156
+ });
157
+ },
158
+ scratch() { // record scratch: resonant noise swept down then back up (stands in when the clip cannot be loaded)
159
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, n = this.noise(), bp = this.filt('bandpass', 2400, 6); bp.frequency.exponentialRampToValueAtTime(300, t + 0.18); bp.frequency.exponentialRampToValueAtTime(1800, t + 0.36);
160
+ n.connect(bp).connect(this.env(t, 0.01, 0.3, 0.08, 0.5)).connect(this.bus); n.start(t); n.stop(t + 0.45);
161
+ },
162
+ badumtss() { // kick, snare, crash
163
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime;
164
+ const kick = (at) => { const o = this.node('sine', 160), g = this.env(at, 0.003, 0.03, 0.22, 0.8); o.frequency.exponentialRampToValueAtTime(48, at + 0.18); o.connect(g).connect(this.bus); o.start(at); o.stop(at + 0.3); };
165
+ const snare = (at) => { const n = this.noise(), g = this.env(at, 0.002, 0.03, 0.16, 0.5); n.connect(this.filt('highpass', 1100)).connect(g).connect(this.bus); n.start(at); n.stop(at + 0.25); const o = this.node('triangle', 190), og = this.env(at, 0.002, 0.02, 0.1, 0.4); o.connect(og).connect(this.bus); o.start(at); o.stop(at + 0.15); };
166
+ const crash = (at) => { const n = this.noise(), g = this.env(at, 0.003, 0.05, 1.4, 0.35); n.connect(this.filt('highpass', 5000)).connect(g).connect(this.bus); n.start(at); n.stop(at + 1.6); };
167
+ kick(t); snare(t + 0.17); crash(t + 0.36);
168
+ },
169
+ sparkle() { if (!this.sound()) return; const t = AUD.ctx.currentTime; [880, 1109, 1319, 1760, 2217].forEach((f, i) => { const at = t + i * 0.06, o = this.node('sine', f), g = this.env(at, 0.005, 0.04, 0.5, 0.14); o.connect(g).connect(this.bus); o.start(at); o.stop(at + 0.6); }); },
170
+ crickets() { // three chirps: a high sine chopped at 38 Hz
171
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime;
172
+ for (let k = 0; k < 3; k++) { const at = t + k * 0.5, o = this.node('sine', 4300), g = this.env(at, 0.01, 0.12, 0.05, 0.08), lfo = this.node('square', 38), lg = c.createGain(); lg.gain.value = 0.08; lfo.connect(lg).connect(g.gain); o.connect(g).connect(this.bus); o.start(at); lfo.start(at); o.stop(at + 0.2); lfo.stop(at + 0.2); }
173
+ },
174
+ ohh() { // a crowd going "ohhh": seven detuned saws sagging through a closing formant
175
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, bp = this.filt('bandpass', 700, 1.2); bp.frequency.exponentialRampToValueAtTime(380, t + 1.1);
176
+ for (let k = 0; k < 7; k++) { const f = 200 + k * 23 + Math.random() * 10, o = this.node('sawtooth', f); o.frequency.linearRampToValueAtTime(f * 0.8, t + 1.1); o.connect(bp); o.start(t); o.stop(t + 1.3); }
177
+ bp.connect(this.env(t, 0.18, 0.4, 0.6, 0.25)).connect(this.bus);
178
+ },
179
+ wub() { // dubstep: a sub saw under a resonant low-pass that an accelerating LFO opens and closes
180
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, lp = this.filt('lowpass', 400, 12), lfo = this.node('sine', 4), lg = c.createGain(); lg.gain.value = 350; lfo.frequency.linearRampToValueAtTime(12, t + 1.2); lfo.connect(lg).connect(lp.frequency);
181
+ [this.node('sawtooth', 55), this.node('square', 55.5)].forEach((o) => { o.connect(lp); o.start(t); o.stop(t + 1.5); }); lp.connect(this.dist(15)).connect(this.env(t, 0.03, 1.1, 0.25, 0.4)).connect(this.bus); lfo.start(t); lfo.stop(t + 1.5);
182
+ },
183
+ yeet() { // a rising whoosh
184
+ if (!this.sound()) return; const c = AUD.ctx, t = c.currentTime, n = this.noise(), bp = this.filt('bandpass', 400, 1); bp.frequency.exponentialRampToValueAtTime(6500, t + 0.35); n.connect(bp).connect(this.env(t, 0.02, 0.2, 0.15, 0.35)).connect(this.bus); n.start(t); n.stop(t + 0.45);
185
+ const o = this.node('sine', 300); o.frequency.exponentialRampToValueAtTime(1400, t + 0.35); o.connect(this.env(t, 0.02, 0.2, 0.1, 0.12)).connect(this.bus); o.start(t); o.stop(t + 0.4);
186
+ },
187
+ async clip(name, fallback) { // a bundled clip, decoded once, played on the meme bus; the synth stands in if it cannot be loaded
188
+ if (!this.sound()) return;
189
+ try {
190
+ if (!this.clips.has(name)) this.clips.set(name, fetch(this.CLIPS[name]).then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.arrayBuffer(); }).then((ab) => AUD.ctx.decodeAudioData(ab)));
191
+ const buf = await this.clips.get(name); if (!this.sound()) return; const s = AUD.ctx.createBufferSource(); s.buffer = buf; s.connect(this.bus); s.start();
192
+ } catch { this.clips.delete(name); if (fallback) fallback(); }
193
+ },
194
+
195
+ // ---------- visuals: everything lands in #memeLayer, nothing there is clickable ----------
196
+ layer() { let l = document.getElementById('memeLayer'); if (!l) { l = document.createElement('div'); l.id = 'memeLayer'; (document.getElementById('center') || document.body).appendChild(l); } return l; },
197
+ el(cls, html, style) { const d = document.createElement('div'); d.className = 'mm ' + cls; d.innerHTML = html; for (const k in style || {}) if (style[k] != null && style[k] !== false) d.style.setProperty(k.startsWith('--') ? k : k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), String(style[k])); this.layer().appendChild(d); return d; },
198
+ gone(d, ms) { setTimeout(() => d.remove(), ms); },
199
+ rect(id) { // a tile's box in layer coordinates, or null when it is not on screen
200
+ const t = document.getElementById('t-' + id); if (!t || t.style.display === 'none' || document.body.classList.contains('tree') || document.body.classList.contains('board')) return null;
201
+ const r = t.getBoundingClientRect(), l = this.layer().getBoundingClientRect(); if (!r.width) return null;
202
+ return { x: r.left - l.left, y: r.top - l.top, w: r.width, h: r.height, cx: r.left - l.left + r.width / 2, cy: r.top - l.top + r.height / 2 };
203
+ },
204
+ slam(text, o = {}) { const d = this.el('slam', esc(text), { left: o.left || 15 + Math.random() * 70 + '%', top: o.top || 15 + Math.random() * 60 + '%', fontSize: (o.size || 48) + 'px', color: o.color || '#fff', '--rot': (o.rot ?? Math.random() * 24 - 12) + 'deg' }); this.gone(d, o.ms || 1400); return d; },
205
+ kinetic(text) { const n = this.cfg().level >= 8 ? 3 : this.cfg().level >= 4 ? 2 : 1; CORE.words(text, n).forEach((w, i) => setTimeout(() => this.active() && this.slam(w, { size: 34 + Math.random() * 40, color: this.PAL[i % this.PAL.length] }), 350 + i * 420)); },
206
+ topBottom(top, bottom, ms = 2200) { this.gone(this.el('tb', `<div class="top">${esc(top)}</div><div class="bot">${esc(bottom || '')}</div>`), ms); },
207
+ flare(x, y, size = 260, ms = 1600) { this.gone(this.el('flare', '', { left: x + 'px', top: y + 'px', width: size + 'px', height: size + 'px', animationDuration: ms + 'ms' }), ms); },
208
+ cross(x, y) { this.gone(this.el('hit', '✕', { left: x + 'px', top: y + 'px' }), 700); },
209
+ rain(emojis, n) { n = n || this.cfg().particles; for (let i = 0; i < n; i++) this.gone(this.el('rain', emojis[i % emojis.length], { left: Math.random() * 100 + '%', animationDelay: Math.random() * 0.8 + 's', animationDuration: 1.6 + Math.random() * 1.6 + 's', fontSize: 18 + Math.random() * 26 + 'px' }), 4200); },
210
+ shake(mul = 1) {
211
+ const amp = this.cfg().shake * mul, c = document.getElementById('center'); if (!c || !c.animate) return;
212
+ const kf = []; for (let i = 0; i < 10; i++) kf.push({ transform: `translate(${(Math.random() * 2 - 1) * amp}px,${(Math.random() * 2 - 1) * amp}px) rotate(${((Math.random() * 2 - 1) * amp) / 6}deg)` }); kf.push({ transform: 'none' });
213
+ c.animate(kf, { duration: 450, easing: 'ease-out' });
214
+ },
215
+ flash(color = '#fff', ms = 110) { if (this.rm || !this.guard.ok()) return; this.gone(this.el('flash', '', { background: color, animationDuration: ms + 'ms' }), ms + 20); }, // never under reduced motion, and at most 3 a second
216
+ zoom(id) { const t = document.getElementById('t-' + id); if (!t) return; t.classList.remove('mm-zoom'); void t.offsetWidth; t.classList.add('mm-zoom'); setTimeout(() => t.classList.remove('mm-zoom'), 520); },
217
+ fry(id, ms = 1800) { const t = document.getElementById('t-' + id); if (!t) return; t.classList.add('mm-fry'); setTimeout(() => t.classList.remove('mm-fry'), ms); },
218
+ wipe(kind = 'star') { if (this.rm) return; this.gone(this.el('wipe ' + kind, ''), 1300); },
219
+ spin() { const s = document.getElementById('stage'); if (!s || !s.animate) return; s.animate([{ transform: 'perspective(900px) rotateY(0)' }, { transform: 'perspective(900px) rotateY(360deg)' }], { duration: 1100, easing: 'cubic-bezier(.6,0,.3,1)' }); },
220
+ shades(id, ms = 3500) { const r = this.rect(id); if (!r) return; this.gone(this.el('shades', '🕶️', { left: r.cx + 'px', top: r.y + 'px', fontSize: Math.max(30, r.w * 0.28) + 'px', '--fall': r.h * 0.3 + 'px' }), ms); },
221
+ triangle(ms = 2600) { this.gone(this.el('tri', '<i></i><span>👁️</span>'), ms); },
222
+ freeze(ms = 2600) { // "To be continued": sepia over the stage and the arrow banner
223
+ const c = document.getElementById('center'); if (!c) return; c.classList.add('mm-freeze'); const d = this.el('tbc', `<span>${esc(T('À suivre', 'To Be Continued'))}</span> ➡︎`);
224
+ setTimeout(() => { c.classList.remove('mm-freeze'); d.remove(); }, ms);
225
+ },
226
+ fling(id) { // the leaving tile is thrown off-screen, then let go
227
+ const t = document.getElementById('t-' + id); if (!t || !t.animate) return; const dir = Math.random() < 0.5 ? -1 : 1;
228
+ const a = t.animate([{ transform: 'none', opacity: 1 }, { transform: `translate(${dir * 120}vw,-40vh) rotate(${dir * 540}deg) scale(.4)`, opacity: 0.6 }], { duration: 900, easing: 'cubic-bezier(.2,.6,.5,1)', fill: 'forwards' });
229
+ setTimeout(() => a.cancel(), 1500);
230
+ },
231
+ };
232
+
233
+ if (typeof module !== 'undefined' && module.exports) module.exports = CORE;
234
+ if (typeof root.document !== 'undefined') root.MEME = MEME;
235
+ })(typeof window !== 'undefined' ? window : globalThis);
Binary file
Binary file
Binary file
Binary file
package/server.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const crypto = require('crypto');
9
+ const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
9
10
  const zlib = require('zlib');
10
11
  const { execFileSync } = require('child_process');
11
12
 
@@ -47,6 +48,8 @@ const toolLabel = (b, ts) => {
47
48
  const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
48
49
  const RESULT_MAX = { AskUserQuestion: 900, Skill: 300, ToolSearch: 500, Read: 1900, Bash: 1300, Grep: 900, Glob: 800, WebFetch: 800, WebSearch: 800, Edit: 200, MultiEdit: 200, Write: 200 };
49
50
  const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
51
+ const HARNESS = /<(system-reminder|browser_instruction|ide_selection|ide_opened_file|task-notification|command-name|command-message|command-args|local-command-stdout|local-command-caveat)>[\s\S]*?<\/\1>\s*/g; // blocks the harness wraps around what the human typed
52
+
50
53
 
51
54
  let sessionsMemo = { at: 0, rows: [] };
52
55
  function listSessions() {
@@ -155,14 +158,24 @@ function boardRows0() {
155
158
  return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
156
159
  });
157
160
  }
161
+ // Settings live in a file, not only in the page's localStorage: the port changes between VS Code windows and reloads, and localStorage is per origin (port included).
162
+ const PREFS = process.env.AGENT_STANDUP_PREFS || (process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'agent-standup', 'prefs.json') : path.join(os.homedir(), '.config', 'agent-standup', 'prefs.json'));
163
+ function readPrefs() { try { const v = JSON.parse(fs.readFileSync(PREFS, 'utf8')); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } }
164
+ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true }); const tmp = PREFS + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, PREFS); }
158
165
  // Most recently active session (optionally limited to one project dir) — drives the live feed.
159
- function currentSession(projFilter, idFilter) {
166
+ const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
167
+ function sessionMtime(proj, id) {
168
+ try { let m = fs.statSync(path.join(ROOT, proj, id + '.jsonl')).mtimeMs; const sd = path.join(ROOT, proj, id, 'subagents'); if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs); return m; } catch { return 0; }
169
+ }
170
+ function currentSession(projFilter, idFilter, stick) {
171
+ const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
172
+ if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-')) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
160
173
  if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl'))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
161
174
  let best = null;
162
175
  if (!fs.existsSync(ROOT)) return {};
163
176
  const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
164
177
  for (const proj of fs.readdirSync(ROOT)) {
165
- if (projFilter && proj !== projFilter) continue;
178
+ if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
166
179
  const dir = path.join(ROOT, proj); let files;
167
180
  try { files = fs.readdirSync(dir); } catch { continue; }
168
181
  for (const f of files) {
@@ -173,7 +186,7 @@ function currentSession(projFilter, idFilter) {
173
186
  consider(proj, id, m);
174
187
  }
175
188
  }
176
- if (!best && projFilter) return currentSession('');
189
+ if (!best && projFilter) return currentSession('', '', stick);
177
190
  return best || {};
178
191
  }
179
192
 
@@ -217,7 +230,7 @@ function parseRecord(st, r) {
217
230
  }
218
231
  if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
219
232
  if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
220
- const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
233
+ const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').replace(HARNESS, '').replace(/^\s*\[Request interrupted[^\]]*\]\s*/i, '').trim(); // what the human typed, without the blocks the harness wraps around it
221
234
  if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
222
235
  } else if (r.type === 'assistant') {
223
236
  const m = r.message || {};
@@ -387,7 +400,7 @@ class TrWorker {
387
400
  }
388
401
  }
389
402
  const trWorkers = [new TrWorker(), new TrWorker()];
390
- const shutdown = () => trWorkers.forEach((w) => w.kill());
403
+ const shutdown = () => { trWorkers.forEach((w) => w.kill()); kokoro.shutdown(); };
391
404
  process.on('exit', shutdown);
392
405
  const pickWorker = () => trWorkers.reduce((a, b) => (a.q.length + a.busy <= b.q.length + b.busy ? a : b));
393
406
  async function translateFast(texts) {
@@ -438,16 +451,17 @@ function synthesize(voice, wpm, text, out) {
438
451
  p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
439
452
  });
440
453
  }
441
- function tts(text, voice, rate) {
442
- const vs = listVoices();
443
- if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice'));
444
- text = String(text).slice(0, 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
445
- const key = crypto.createHash('sha1').update([voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
454
+ function tts(text, voice, rate, engine) {
455
+ const kk = engine === 'kokoro';
456
+ if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
457
+ else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
458
+ text = String(text).slice(0, kk ? 1500 : 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
459
+ const key = crypto.createHash('sha1').update([kk ? 'kokoro' : 'system', voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
446
460
  if (fs.existsSync(file)) return Promise.resolve(file);
447
461
  if (ttsJobs.has(key)) return ttsJobs.get(key);
448
462
  fs.mkdirSync(ttsDir, { recursive: true });
449
463
  const tmp = file + '.part.wav';
450
- const job = synthesize(voice, rate, text, tmp).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
464
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp) : synthesize(voice, rate, text, tmp)).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
451
465
  ttsJobs.set(key, job); return job;
452
466
  }
453
467
 
@@ -477,7 +491,7 @@ function start(port = PORT) {
477
491
  const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
478
492
  // Endpoints with side effects (they spawn programs or claim the sound) refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
479
493
  const sfs = req.headers['sec-fetch-site'];
480
- if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
494
+ if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
481
495
  const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
482
496
  let body = '', over = false; req.setTimeout(10000, () => req.destroy());
483
497
  req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
@@ -501,13 +515,21 @@ function start(port = PORT) {
501
515
  }
502
516
  if (url.pathname === '/api/health') return json({ claude: findClaude(), apiKey: !!process.env.ANTHROPIC_API_KEY, trError: lastTrError, platform: process.platform });
503
517
  if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
504
- if (url.pathname === '/api/voices') return json(listVoices());
518
+ if (url.pathname === '/api/voices') {
519
+ if (url.searchParams.get('engine') === 'kokoro') { const st = kokoro.status(); if (st.installed && !st.gaveUp) kokoro.warm(); return json({ available: st.installed && !st.gaveUp, ready: st.ready, voices: kokoro.voices(), platform: process.platform, engine: 'kokoro' }); }
520
+ return json(listVoices());
521
+ }
522
+ if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
523
+ if (url.pathname === '/api/kokoro') return json(kokoro.status());
505
524
  if (url.pathname === '/api/tts') {
506
- return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'))
525
+ return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
507
526
  .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
508
527
  .catch((e) => json({ error: String(e.message) }, 400));
509
528
  }
510
- if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || ''));
529
+ if (url.pathname === '/api/prefs.js') { res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' }); return res.end('window.SAVED_PREFS = ' + JSON.stringify(readPrefs()).replace(/</g, '\\u003c') + ';'); }
530
+ if (url.pathname === '/api/prefs' && req.method === 'PUT') return readBody((body) => { try { const v = JSON.parse(body); if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('prefs must be an object'); writePrefs(v); json({ ok: true }); } catch (e) { json({ error: String(e.message || e) }, 400); } });
531
+ if (url.pathname === '/api/prefs') return json(readPrefs() || {});
532
+ if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || '', { proj: url.searchParams.get('sproj') || '', id: url.searchParams.get('sid') || '' }));
511
533
  if (url.pathname === '/api/board') return json(boardRows());
512
534
  if (url.pathname === '/api/characters') return json(listCharacters());
513
535
  if (url.pathname === '/api/session') {