@double_geste/agent-standup 1.23.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kokoro-worker.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs'), path = require('path'), readline = require('readline'), { createRequire } = require('module');
5
5
  const MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
6
6
  const send = (o) => fs.writeSync(1, JSON.stringify(o) + '\n');
7
+ const finite = (samples) => { for (let i = 0; i < samples.length; i++) if (samples[i] !== samples[i]) return false; return true; }; // a quantised model can return NaN for a whole line, which would be written as silence
7
8
  function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
8
9
  const n = samples.length, buf = Buffer.alloc(44 + n * 2);
9
10
  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);
@@ -18,7 +19,11 @@ async function main() {
18
19
  send({ ready: true });
19
20
  for await (const line of readline.createInterface({ input: process.stdin })) {
20
21
  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
+ try {
23
+ let a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 });
24
+ if (!finite(a.audio)) { a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 }); if (!finite(a.audio)) throw new Error('the model returned NaN'); } // made once more; if it fails again the server hands the line to a system voice
25
+ fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate });
26
+ }
22
27
  catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
23
28
  }
24
29
  }
package/kokoro.js CHANGED
@@ -4,11 +4,11 @@ const fs = require('fs'), os = require('os'), path = require('path'), { spawn }
4
4
 
5
5
  const WIN = process.platform === 'win32';
6
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;
7
+ const PKG = 'kokoro-js@1.2.1', DTYPE = 'q4', SIZE_MB = 730, MAX_RESTARTS = 3;
8
8
  const WORKER = process.env.AGENT_STANDUP_KOKORO_WORKER || path.join(__dirname, 'kokoro-worker.js'), LOG = path.join(DIR, 'install.log');
9
9
 
10
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 }));
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_sarah', 'Sarah', 'en_US'], ['am_puck', 'Puck', 'en_US'], ['ff_siwis', 'Siwis', 'fr_FR']].map(([name, label, locale]) => ({ name, label, locale }));
12
12
  const voices = () => VOICES, hasVoice = (n) => VOICES.some((v) => v.name === n);
13
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
14
  const exists = (f) => { try { fs.statSync(f); return true; } catch { return false; } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.23.0",
3
+ "version": "1.23.1",
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"
package/public/index.html CHANGED
@@ -237,8 +237,6 @@
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>
242
240
  <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>
243
241
  <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>
244
242
  <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>
@@ -285,8 +283,6 @@
285
283
  </footer>
286
284
  <div id="card"></div><div id="recap"><div class="box"></div></div>
287
285
 
288
- <link rel="stylesheet" href="/meme.css">
289
- <script src="/meme.js"></script>
290
286
  <script src="/api/prefs.js"></script>
291
287
  <script>
292
288
  // =====================================================================
@@ -542,7 +538,7 @@ const AUD = {
542
538
  const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
543
539
  const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
544
540
  },
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(); },
541
+ 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(); },
546
542
  // Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
547
543
  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); },
548
544
  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); },
@@ -1166,7 +1162,7 @@ function vanish(id) {
1166
1162
  S.gone.add(id); const t = $('#t-' + id), p = S.agents[id]; if (!t || !p) return;
1167
1163
  toast(`${p.name} ${L('a quitté l’appel', 'left the call')} 👋`);
1168
1164
  if (t.style.display === 'none' || document.body.classList.contains('tree')) return;
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);
1165
+ 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);
1170
1166
  setTimeout(() => { S.leaving.delete(id); t.classList.remove('vanishing'); if (S.gone.has(id)) t.style.display = 'none'; }, 1250); };
1171
1167
  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
1172
1168
  }
@@ -1181,7 +1177,7 @@ const mouths = new Map();
1181
1177
  function startTalk(id, text, label) {
1182
1178
  const t = $('#t-' + id); if (!t) return;
1183
1179
  if (S.present && S.present !== id) hidePresent();
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 });
1180
+ 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);
1185
1181
  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
1182
  if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
1187
1183
  const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
@@ -1198,9 +1194,9 @@ setInterval(() => { // now and then a busy agent has a little thought; never w
1198
1194
  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');
1199
1195
  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)]); }
1200
1196
  }, 7000);
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, '😬'); }
1197
+ 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, '😬'); }
1202
1198
  function confetti() {
1203
- MEME.fire('win'); if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1199
+ if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1204
1200
  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); }
1205
1201
  }
1206
1202
  function stopTalk(id) {
@@ -1382,7 +1378,7 @@ async function react(ev, token) {
1382
1378
  if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1383
1379
  }
1384
1380
  async function card(text, sub, ms = 2500) {
1385
- MEME.fire('card', { text });
1381
+
1386
1382
  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);
1387
1383
  await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
1388
1384
  }
@@ -1414,7 +1410,7 @@ async function run(i) {
1414
1410
  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; }
1415
1411
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1416
1412
  if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / 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; }
1413
+ 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; }
1418
1414
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1419
1415
  if (isSub(ev.agent) && ev.kind !== 'order') {
1420
1416
  while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
@@ -1427,14 +1423,14 @@ async function run(i) {
1427
1423
  }
1428
1424
  }
1429
1425
  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) });
1426
+ if (group.length >= 3)
1431
1427
  prefetchQuips(i); warmTTS(i);
1432
1428
  const playOne = (g) => {
1433
1429
  applyStatus(g);
1434
1430
  const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = spoken(g);
1435
1431
  startTalk(g.agent, line, label);
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); }
1432
+ if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1433
+ if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1438
1434
  return speakLine(clipSpeech(line, S.pace), S.agents[g.agent], true, token, FR() && trMem.has(baseText(g))).then(() => stopTalk(g.agent));
1439
1435
  };
1440
1436
  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
@@ -1675,7 +1671,7 @@ $('#langSel').onchange = () => setLang($('#langSel').value, true);
1675
1671
  $('#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(); };
1676
1672
  $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1677
1673
  $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
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(); }
1674
+ $('#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(); }
1679
1675
  if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
1680
1676
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1681
1677
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
@@ -1690,17 +1686,13 @@ async function kokoroUI() {
1690
1686
  b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
1691
1687
  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
1688
  : 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)…'))
1689
+ : 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 305 Mo)…', 'Loading the model (downloaded on first use, about 305 MB)…'))
1694
1690
  : 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
1691
  if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
1696
1692
  if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
1697
1693
  }
1698
1694
  $('#kokoroInstall').onclick = async () => { try { await fetch('/api/kokoro/install', { method: 'POST' }); } catch {} kokoroUI(); };
1699
1695
  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(); };
1704
1696
  $('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
1705
1697
  $('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1706
1698
  $('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
@@ -1728,14 +1720,13 @@ if (EMBED) $('#mBrowser').style.display = '';
1728
1720
 
1729
1721
  (async function init() {
1730
1722
  if (!LANGS.some((l) => l[0] === prefs.lang)) prefs.lang = 'en'; await loadLocale(prefs.lang); await loadChars();
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);
1723
+ document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); applyMute(); TTS.init().then(updateHint);
1732
1724
  loadHealth().then(() => { // French by default only if translation can actually work; otherwise stay in English without a warning
1733
1725
  const h = S.health || {};
1734
1726
  if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
1735
1727
  if (FR()) fetch('/api/warm').catch(() => {});
1736
1728
  });
1737
1729
  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
1739
1730
  if (Q.get('demo')) {
1740
1731
  if (Q.get('view') === 'tree') setView('tree');
1741
1732
  await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
@@ -166,13 +166,10 @@
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
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
169
  "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
170
  "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
171
  "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)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
177
174
  "The engine stopped: system voices are used instead.": "Die Engine wurde beendet: stattdessen kommen die Systemstimmen zum Einsatz.",
178
175
  "Very natural voices (installs on demand)": "Sehr natürliche Stimmen (werden bei Bedarf installiert)",
@@ -8,7 +8,6 @@
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)",
12
11
  "Agent voices": "Agent voices",
13
12
  "Agents": "Agents",
14
13
  "Agents list (left)": "Agents list (left)",
@@ -47,7 +46,6 @@
47
46
  "duration": "duration",
48
47
  "Editing ": "Editing ",
49
48
  "Editor": "Editor",
50
- "Effects volume": "Effects volume",
51
49
  "EPISODE 1": "EPISODE 1",
52
50
  "Episode recap": "Episode recap",
53
51
  "errors": "errors",
@@ -65,7 +63,6 @@
65
63
  "idle": "idle",
66
64
  "input": "input",
67
65
  "Installing…": "Installing…",
68
- "Intensity": "Intensity",
69
66
  "is sharing their screen": "is sharing their screen",
70
67
  "Join the call": "Join the call",
71
68
  "Language": "Language",
@@ -76,7 +73,7 @@
76
73
  "lines": "lines",
77
74
  "Listing ": "Listing ",
78
75
  "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)…",
76
+ "Loading the model (downloaded on first use, about 305 MB)…": "Loading the model (downloaded on first use, about 305 MB)…",
80
77
  "longest wait ": "longest wait ",
81
78
  "Looking for a tool": "Looking for a tool",
82
79
  "looping?": "looping?",
@@ -166,13 +166,10 @@
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
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
169
  "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
170
  "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
171
  "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)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Cargando el modelo (se descarga la primera vez, unos 305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "No se encontró npm: instala Node.js para activar esta opción.",
177
174
  "The engine stopped: system voices are used instead.": "El motor se detuvo: se usan las voces del sistema.",
178
175
  "Very natural voices (installs on demand)": "Voces muy naturales (se instalan bajo demanda)",
@@ -166,13 +166,10 @@
166
166
  "custom character(s) loaded": "件のカスタムキャラクターを読み込みました",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "このフォルダにドット絵(PNG)を入れると、キャラクターを差し替えられます:",
168
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
169
  "Downloading, you can carry on: system voices are used meanwhile.": "ダウンロード中です。そのまま作業を続けられます。それまではシステム音声を使います。",
171
- "Effects volume": "効果音の音量",
172
170
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質の良いローカル音声(主に英語、フランス語は1種類のみ)。無料で、データがPCの外に出ることはありません。",
173
171
  "Installing…": "インストール中…",
174
- "Intensity": "強度",
175
- "Loading the model (downloaded on first use, about 155 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約155 MB)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "npm が見つかりません。このオプションを使うには Node.js をインストールしてください。",
177
174
  "The engine stopped: system voices are used instead.": "エンジンが停止しました。代わりにシステム音声を使います。",
178
175
  "Very natural voices (installs on demand)": "とても自然な音声(必要時にインストール)",
@@ -166,13 +166,10 @@
166
166
  "You": "Você",
167
167
  "Your agents are done 🎉": "Seus agentes terminaram 🎉",
168
168
  "your turn": "sua vez",
169
- "🔥 Meme mode (unhinged)": "🔥 Modo meme (sem noção)",
170
169
  "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
170
  "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
171
  "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)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
177
174
  "The engine stopped: system voices are used instead.": "O mecanismo parou: as vozes do sistema são usadas no lugar.",
178
175
  "Very natural voices (installs on demand)": "Vozes muito naturais (instaladas sob demanda)",
@@ -166,13 +166,10 @@
166
166
  "custom character(s) loaded": "个自定义角色已加载",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素画(PNG)放进此文件夹即可替换角色:",
168
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
169
  "Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
171
- "Effects volume": "音效音量",
172
170
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
173
171
  "Installing…": "正在安装…",
174
- "Intensity": "强度",
175
- "Loading the model (downloaded on first use, about 155 MB)…": "正在加载模型(首次使用时下载,约 155 MB)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "正在加载模型(首次使用时下载,约 305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
177
174
  "The engine stopped: system voices are used instead.": "引擎已停止:改用系统语音。",
178
175
  "Very natural voices (installs on demand)": "超自然语音(按需安装)",
@@ -166,13 +166,10 @@
166
166
  "You": "你",
167
167
  "Your agents are done 🎉": "你的代理都完成了 🎉",
168
168
  "your turn": "換你了",
169
- "🔥 Meme mode (unhinged)": "🔥 迷因模式(完全失控)",
170
169
  "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
171
- "Effects volume": "音效音量",
172
170
  "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
173
171
  "Installing…": "安裝中…",
174
- "Intensity": "強度",
175
- "Loading the model (downloaded on first use, about 155 MB)…": "正在載入模型(第一次使用時下載,約 155 MB)…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "正在載入模型(第一次使用時下載,約 305 MB)…",
176
173
  "npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
177
174
  "The engine stopped: system voices are used instead.": "引擎已停止:改用系統語音。",
178
175
  "Very natural voices (installs on demand)": "超自然語音(需要時才安裝)",
package/server.js CHANGED
@@ -461,7 +461,8 @@ function tts(text, voice, rate, engine) {
461
461
  if (ttsJobs.has(key)) return ttsJobs.get(key);
462
462
  fs.mkdirSync(ttsDir, { recursive: true });
463
463
  const tmp = file + '.part.wav';
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));
464
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.locale.startsWith((voice.startsWith('ff_') ? 'fr' : 'en') + '_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped
465
+ .then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
465
466
  ttsJobs.set(key, job); return job;
466
467
  }
467
468
 
package/public/meme.css DELETED
@@ -1,60 +0,0 @@
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 DELETED
@@ -1,235 +0,0 @@
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