@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.
- package/kokoro-worker.js +25 -0
- package/kokoro.js +82 -0
- package/package.json +3 -1
- package/public/index.html +86 -23
- package/public/locales/de.json +12 -1
- package/public/locales/en.json +11 -0
- package/public/locales/es.json +12 -1
- package/public/locales/ja.json +12 -1
- package/public/locales/pt-BR.json +17 -2
- package/public/locales/zh-CN.json +12 -1
- package/public/locales/zh-TW.json +17 -6
- package/public/meme.css +60 -0
- package/public/meme.js +235 -0
- package/public/sfx/oh-my-god.mp3 +0 -0
- package/public/sfx/scratch-1.mp3 +0 -0
- package/public/sfx/scratch-2.mp3 +0 -0
- package/public/sfx/scratch-3.mp3 +0 -0
- package/server.js +37 -15
package/kokoro-worker.js
ADDED
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1147
|
-
if (S.present === id) { $('#pcap').textContent =
|
|
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); }
|
|
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');
|
package/public/locales/de.json
CHANGED
|
@@ -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
|
}
|
package/public/locales/en.json
CHANGED
|
@@ -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",
|
package/public/locales/es.json
CHANGED
|
@@ -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
|
}
|
package/public/locales/ja.json
CHANGED
|
@@ -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": {
|
|
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)": "
|
|
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)": "
|
|
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
|
}
|
package/public/meme.css
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
-
|
|
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
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
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')
|
|
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/
|
|
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') {
|