@double_geste/agent-standup 1.22.1 → 1.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kokoro-worker.js +30 -0
- package/kokoro.js +82 -0
- package/package.json +3 -1
- package/public/index.html +68 -14
- package/public/locales/de.json +9 -1
- package/public/locales/en.json +8 -0
- package/public/locales/es.json +9 -1
- package/public/locales/ja.json +9 -1
- package/public/locales/pt-BR.json +14 -2
- package/public/locales/zh-CN.json +9 -1
- package/public/locales/zh-TW.json +14 -6
- package/server.js +38 -15
package/kokoro-worker.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
const finite = (samples) => { for (let i = 0; i < samples.length; i++) if (samples[i] !== samples[i]) return false; return true; }; // a quantised model can return NaN for a whole line, which would be written as silence
|
|
8
|
+
function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
|
|
9
|
+
const n = samples.length, buf = Buffer.alloc(44 + n * 2);
|
|
10
|
+
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
|
|
11
|
+
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); }
|
|
12
|
+
return buf;
|
|
13
|
+
}
|
|
14
|
+
async function main() {
|
|
15
|
+
const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4f16';
|
|
16
|
+
const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
|
|
17
|
+
const tf = req('@huggingface/transformers'); tf.env.cacheDir = path.join(dir, 'models');
|
|
18
|
+
const { KokoroTTS } = req('kokoro-js'); const tts = await KokoroTTS.from_pretrained(MODEL, { dtype, device: 'cpu' });
|
|
19
|
+
send({ ready: true });
|
|
20
|
+
for await (const line of readline.createInterface({ input: process.stdin })) {
|
|
21
|
+
let m; try { m = JSON.parse(line); } catch { continue; }
|
|
22
|
+
try {
|
|
23
|
+
let a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 });
|
|
24
|
+
if (!finite(a.audio)) { a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 }); if (!finite(a.audio)) throw new Error('the model returned NaN'); } // made once more; if it fails again the server hands the line to a system voice
|
|
25
|
+
fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate });
|
|
26
|
+
}
|
|
27
|
+
catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
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 = 'q4', SIZE_MB = 730, 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_sarah', 'Sarah', '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.1",
|
|
4
4
|
"description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
|
@@ -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
|
@@ -242,6 +242,7 @@
|
|
|
242
242
|
<button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
|
|
243
243
|
<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
244
|
<div class="box" id="vstyleBox"><select id="vstyle"></select></div>
|
|
245
|
+
<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
246
|
<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
247
|
<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
248
|
<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 +283,7 @@
|
|
|
282
283
|
</footer>
|
|
283
284
|
<div id="card"></div><div id="recap"><div class="box"></div></div>
|
|
284
285
|
|
|
286
|
+
<script src="/api/prefs.js"></script>
|
|
285
287
|
<script>
|
|
286
288
|
// =====================================================================
|
|
287
289
|
// helpers, prefs, i18n
|
|
@@ -297,10 +299,12 @@ const EMBED = Q.get('embed') === '1' || window.parent !== window;
|
|
|
297
299
|
if (Q.get('pin')) S.pin = Q.get('pin');
|
|
298
300
|
|
|
299
301
|
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 {
|
|
302
|
+
{ 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
|
|
303
|
+
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
304
|
if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
|
|
302
305
|
if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
|
|
303
|
-
|
|
306
|
+
let prefsTimer = null;
|
|
307
|
+
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
308
|
// 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
309
|
// A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
|
|
306
310
|
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 +538,7 @@ const AUD = {
|
|
|
534
538
|
const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
|
|
535
539
|
const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
|
|
536
540
|
},
|
|
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();
|
|
541
|
+
apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
|
|
538
542
|
// Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
|
|
539
543
|
applyMusic() { if (!this.ctx) return; this.amb.gain.setTargetAtTime((this.ducked ? 0.35 : 1) * (prefs.officeVol ?? 0.8), this.ctx.currentTime, 0.25); const v = prefs.music && !this.off() ? (prefs.musicVol ?? 0.35) * (this.ducked ? 0.16 : 1) : 0; this.music.gain.setTargetAtTime(v, this.ctx.currentTime, this.ducked ? 0.12 : 0.7); },
|
|
540
544
|
blip(f) { if (!this.ctx || this.off()) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.1); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.25); },
|
|
@@ -652,17 +656,23 @@ function rankVoices(list) { // list of names → best distinct voices first
|
|
|
652
656
|
return out.slice(0, 5);
|
|
653
657
|
}
|
|
654
658
|
const TTS = {
|
|
655
|
-
server: false, platform: '', pool: { fr: [], en: [] }, cache: new Map(),
|
|
659
|
+
server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
|
|
656
660
|
async init() {
|
|
657
|
-
|
|
661
|
+
this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
|
|
662
|
+
try {
|
|
663
|
+
if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
|
|
664
|
+
const k = await (await fetch('/api/voices?engine=kokoro')).json();
|
|
665
|
+
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; }
|
|
666
|
+
}
|
|
667
|
+
const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
|
|
658
668
|
for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
|
|
659
669
|
},
|
|
660
670
|
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
671
|
rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
|
|
662
672
|
buf(text, voice, rate) {
|
|
663
|
-
const key = voice + '|' + rate + '|' + text;
|
|
673
|
+
const key = this.engine + '|' + voice + '|' + rate + '|' + text;
|
|
664
674
|
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)));
|
|
675
|
+
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
676
|
if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
|
|
667
677
|
this.cache.get(key).catch(() => this.cache.delete(key));
|
|
668
678
|
}
|
|
@@ -745,12 +755,12 @@ async function speakServer(text, p, gain, token, fr = FR()) {
|
|
|
745
755
|
const buf = await TTS.buf(speakable(text), voice, TTS.rate()); if (token !== S.token) return;
|
|
746
756
|
const c = AUD.ctx, src = c.createBufferSource(); src.buffer = buf;
|
|
747
757
|
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); }
|
|
758
|
+
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
759
|
an.connect(g); g.connect(AUD.voice);
|
|
750
760
|
const data = new Uint8Array(an.fftSize);
|
|
751
761
|
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
762
|
AUD.live.add(src);
|
|
753
|
-
await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
|
|
763
|
+
captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
|
|
754
764
|
AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
|
|
755
765
|
}
|
|
756
766
|
function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
|
|
@@ -770,7 +780,32 @@ function speakBrowser(text, p, token, fr = FR()) { // fallback where no server
|
|
|
770
780
|
next();
|
|
771
781
|
});
|
|
772
782
|
}
|
|
783
|
+
// 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.
|
|
784
|
+
function pieces(text) {
|
|
785
|
+
const out = [];
|
|
786
|
+
for (const sen of String(text).split(/(?<=[.!?…]["')\]]*)\s+/)) {
|
|
787
|
+
const t = sen.trim(); if (!t) continue;
|
|
788
|
+
if (t.length <= 110) { out.push(t); continue; }
|
|
789
|
+
let cur = '';
|
|
790
|
+
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; }
|
|
791
|
+
if (cur) out.push(cur);
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
const capTimers = new Map(); // agent id → timers that advance its subtitle
|
|
796
|
+
function captionClear(id) { (capTimers.get(id) || []).forEach(clearTimeout); capTimers.delete(id); }
|
|
797
|
+
function setCaption(id, text) {
|
|
798
|
+
const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
|
|
799
|
+
b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
|
|
800
|
+
}
|
|
801
|
+
function captionRun(id, text, totalMs) {
|
|
802
|
+
captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
|
|
803
|
+
const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
|
|
804
|
+
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; }
|
|
805
|
+
capTimers.set(id, timers);
|
|
806
|
+
}
|
|
773
807
|
async function speakLine(text, p, primary, token, fr = FR()) {
|
|
808
|
+
captionRun(p.id, text, estMs(text));
|
|
774
809
|
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
810
|
if (S.hurry) return sleep(estMs(text));
|
|
776
811
|
if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
|
|
@@ -1143,8 +1178,8 @@ function startTalk(id, text, label) {
|
|
|
1143
1178
|
const t = $('#t-' + id); if (!t) return;
|
|
1144
1179
|
if (S.present && S.present !== id) hidePresent();
|
|
1145
1180
|
if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
|
|
1146
|
-
const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(
|
|
1147
|
-
if (S.present === id) { $('#pcap').textContent =
|
|
1181
|
+
const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
|
|
1182
|
+
if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
|
|
1148
1183
|
const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
|
|
1149
1184
|
mouths.set(id, setInterval(() => {
|
|
1150
1185
|
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
|
|
@@ -1165,6 +1200,7 @@ function confetti() {
|
|
|
1165
1200
|
for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
|
|
1166
1201
|
}
|
|
1167
1202
|
function stopTalk(id) {
|
|
1203
|
+
captionClear(id);
|
|
1168
1204
|
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
1205
|
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
1206
|
const t = $('#t-' + id); if (!t) return;
|
|
@@ -1342,6 +1378,7 @@ async function react(ev, token) {
|
|
|
1342
1378
|
if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
|
|
1343
1379
|
}
|
|
1344
1380
|
async function card(text, sub, ms = 2500) {
|
|
1381
|
+
|
|
1345
1382
|
const c = $('#card'); c.innerHTML = `<div><h1>${esc(text)}</h1><p>${esc(sub || '')}</p></div>`; c.classList.add('on'); AUD.tone(392, .5, 'triangle', .12); AUD.tone(523, .6, 'triangle', .12, .25);
|
|
1346
1383
|
await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
|
|
1347
1384
|
}
|
|
@@ -1386,6 +1423,7 @@ async function run(i) {
|
|
|
1386
1423
|
}
|
|
1387
1424
|
}
|
|
1388
1425
|
if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
|
|
1426
|
+
if (group.length >= 3)
|
|
1389
1427
|
prefetchQuips(i); warmTTS(i);
|
|
1390
1428
|
const playOne = (g) => {
|
|
1391
1429
|
applyStatus(g);
|
|
@@ -1450,7 +1488,7 @@ async function fillProjects() { // live view: follow the latest activity anywh
|
|
|
1450
1488
|
}
|
|
1451
1489
|
$('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
|
|
1452
1490
|
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();
|
|
1491
|
+
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
1492
|
if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
|
|
1455
1493
|
if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
|
|
1456
1494
|
if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
|
|
@@ -1638,7 +1676,23 @@ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b)
|
|
|
1638
1676
|
$('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
|
|
1639
1677
|
$('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
|
|
1640
1678
|
$('#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(); };
|
|
1679
|
+
$('#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(); };
|
|
1680
|
+
// ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
|
|
1681
|
+
let kokoroTimer = null;
|
|
1682
|
+
async function kokoroUI() {
|
|
1683
|
+
const box = $('#kokoroBox'), on = prefs.vstyle === 'kokoro'; box.style.display = on ? '' : 'none'; if (!on) { clearInterval(kokoroTimer); kokoroTimer = null; return; }
|
|
1684
|
+
let st; try { st = await (await fetch('/api/kokoro')).json(); } catch { return; }
|
|
1685
|
+
const b = $('#kokoroInstall'), h = $('#kokoroHint'); b.style.display = st.installed ? 'none' : '';
|
|
1686
|
+
b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
|
|
1687
|
+
h.textContent = st.installing ? L('Téléchargement en cours, vous pouvez continuer : les voix du système servent en attendant.', 'Downloading, you can carry on: system voices are used meanwhile.')
|
|
1688
|
+
: st.error ? st.error : !st.npm && !st.installed ? L('npm est introuvable : installez Node.js pour activer cette option.', 'npm not found: install Node.js to enable this option.')
|
|
1689
|
+
: st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') : L('Chargement du modèle (téléchargé au premier usage, environ 305 Mo)…', 'Loading the model (downloaded on first use, about 305 MB)…'))
|
|
1690
|
+
: L('Voix locales de bonne qualité, en anglais surtout (une seule voix française). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.');
|
|
1691
|
+
if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
|
|
1692
|
+
if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
|
|
1693
|
+
}
|
|
1694
|
+
$('#kokoroInstall').onclick = async () => { try { await fetch('/api/kokoro/install', { method: 'POST' }); } catch {} kokoroUI(); };
|
|
1695
|
+
kokoroUI();
|
|
1642
1696
|
$('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
|
|
1643
1697
|
$('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
|
|
1644
1698
|
$('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
|
|
@@ -1672,7 +1726,7 @@ if (EMBED) $('#mBrowser').style.display = '';
|
|
|
1672
1726
|
if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
|
|
1673
1727
|
if (FR()) fetch('/api/warm').catch(() => {});
|
|
1674
1728
|
});
|
|
1675
|
-
if (Q.get('lang')) { await setLang(Q.get('lang'), false); }
|
|
1729
|
+
if (Q.get('lang')) { await setLang(Q.get('lang'), false); }
|
|
1676
1730
|
if (Q.get('demo')) {
|
|
1677
1731
|
if (Q.get('view') === 'tree') setView('tree');
|
|
1678
1732
|
await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
|
package/public/locales/de.json
CHANGED
|
@@ -165,5 +165,13 @@
|
|
|
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
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "Wird heruntergeladen, du kannst weitermachen: bis dahin kommen die Systemstimmen zum Einsatz.",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, vor allem Englisch (nur eine französische Stimme). Kostenlos, nichts verlässt deinen Rechner.",
|
|
171
|
+
"Installing…": "Installation läuft…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "Die Engine wurde beendet: stattdessen kommen die Systemstimmen zum Einsatz.",
|
|
175
|
+
"Very natural voices (installs on demand)": "Sehr natürliche Stimmen (werden bei Bedarf installiert)",
|
|
176
|
+
"Voices ready.": "Stimmen bereit."
|
|
169
177
|
}
|
package/public/locales/en.json
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"done": "done",
|
|
42
42
|
"Done": "Done",
|
|
43
43
|
"Done. ": "Done. ",
|
|
44
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "Downloading, you can carry on: system voices are used meanwhile.",
|
|
44
45
|
"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
46
|
"duration": "duration",
|
|
46
47
|
"Editing ": "Editing ",
|
|
@@ -56,10 +57,12 @@
|
|
|
56
57
|
"Files touched": "Files touched",
|
|
57
58
|
"Final report": "Final report",
|
|
58
59
|
"first error": "first error",
|
|
60
|
+
"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
61
|
"Highlights": "Highlights",
|
|
60
62
|
"Human": "Human",
|
|
61
63
|
"idle": "idle",
|
|
62
64
|
"input": "input",
|
|
65
|
+
"Installing…": "Installing…",
|
|
63
66
|
"is sharing their screen": "is sharing their screen",
|
|
64
67
|
"Join the call": "Join the call",
|
|
65
68
|
"Language": "Language",
|
|
@@ -70,6 +73,7 @@
|
|
|
70
73
|
"lines": "lines",
|
|
71
74
|
"Listing ": "Listing ",
|
|
72
75
|
"Little lines & animations": "Little lines & animations",
|
|
76
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "Loading the model (downloaded on first use, about 305 MB)…",
|
|
73
77
|
"longest wait ": "longest wait ",
|
|
74
78
|
"Looking for a tool": "Looking for a tool",
|
|
75
79
|
"looping?": "looping?",
|
|
@@ -84,6 +88,7 @@
|
|
|
84
88
|
"No recent session": "No recent session",
|
|
85
89
|
"No system voice found: agents will mumble.": "No system voice found: agents will mumble.",
|
|
86
90
|
"Notify me when agents finish": "Notify me when agents finish",
|
|
91
|
+
"npm not found: install Node.js to enable this option.": "npm not found: install Node.js to enable this option.",
|
|
87
92
|
"Office ambience (keys, coffee…)": "Office ambience (keys, coffee…)",
|
|
88
93
|
"Office music (FIP radio)": "Office music (FIP radio)",
|
|
89
94
|
"OFFLINE": "OFFLINE",
|
|
@@ -134,6 +139,7 @@
|
|
|
134
139
|
"task": "task",
|
|
135
140
|
"Tasks": "Tasks",
|
|
136
141
|
"THE END": "THE END",
|
|
142
|
+
"The engine stopped: system voices are used instead.": "The engine stopped: system voices are used instead.",
|
|
137
143
|
"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
144
|
"Time": "Time",
|
|
139
145
|
"to": "to",
|
|
@@ -149,6 +155,8 @@
|
|
|
149
155
|
"Untitled session": "Untitled session",
|
|
150
156
|
"Updating todo list": "Updating todo list",
|
|
151
157
|
"Using skill ": "Using skill ",
|
|
158
|
+
"Very natural voices (installs on demand)": "Very natural voices (installs on demand)",
|
|
159
|
+
"Voices ready.": "Voices ready.",
|
|
152
160
|
"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
161
|
"waiting": "waiting",
|
|
154
162
|
"WAITING": "WAITING",
|
package/public/locales/es.json
CHANGED
|
@@ -165,5 +165,13 @@
|
|
|
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
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "Descargando, puedes seguir: mientras tanto se usan las voces del sistema.",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Voces locales de buena calidad, sobre todo en inglés (una sola voz en francés). Gratis, nada sale de tu equipo.",
|
|
171
|
+
"Installing…": "Instalando…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "Cargando el modelo (se descarga la primera vez, unos 305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "No se encontró npm: instala Node.js para activar esta opción.",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "El motor se detuvo: se usan las voces del sistema.",
|
|
175
|
+
"Very natural voices (installs on demand)": "Voces muy naturales (se instalan bajo demanda)",
|
|
176
|
+
"Voices ready.": "Voces listas."
|
|
169
177
|
}
|
package/public/locales/ja.json
CHANGED
|
@@ -165,5 +165,13 @@
|
|
|
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
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "ダウンロード中です。そのまま作業を続けられます。それまではシステム音声を使います。",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質の良いローカル音声(主に英語、フランス語は1種類のみ)。無料で、データがPCの外に出ることはありません。",
|
|
171
|
+
"Installing…": "インストール中…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "npm が見つかりません。このオプションを使うには Node.js をインストールしてください。",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "エンジンが停止しました。代わりにシステム音声を使います。",
|
|
175
|
+
"Very natural voices (installs on demand)": "とても自然な音声(必要時にインストール)",
|
|
176
|
+
"Voices ready.": "音声の準備ができました。"
|
|
169
177
|
}
|
|
@@ -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,13 @@
|
|
|
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
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
|
|
171
|
+
"Installing…": "Instalando…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "O mecanismo parou: as vozes do sistema são usadas no lugar.",
|
|
175
|
+
"Very natural voices (installs on demand)": "Vozes muito naturais (instaladas sob demanda)",
|
|
176
|
+
"Voices ready.": "Vozes prontas."
|
|
165
177
|
}
|
|
@@ -165,5 +165,13 @@
|
|
|
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
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
|
|
171
|
+
"Installing…": "正在安装…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "正在加载模型(首次使用时下载,约 305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "引擎已停止:改用系统语音。",
|
|
175
|
+
"Very natural voices (installs on demand)": "超自然语音(按需安装)",
|
|
176
|
+
"Voices ready.": "语音已就绪。"
|
|
169
177
|
}
|
|
@@ -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,13 @@
|
|
|
165
165
|
"Writing ": "撰寫 ",
|
|
166
166
|
"You": "你",
|
|
167
167
|
"Your agents are done 🎉": "你的代理都完成了 🎉",
|
|
168
|
-
"your turn": "換你了"
|
|
168
|
+
"your turn": "換你了",
|
|
169
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
|
|
170
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
|
|
171
|
+
"Installing…": "安裝中…",
|
|
172
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "正在載入模型(第一次使用時下載,約 305 MB)…",
|
|
173
|
+
"npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
|
|
174
|
+
"The engine stopped: system voices are used instead.": "引擎已停止:改用系統語音。",
|
|
175
|
+
"Very natural voices (installs on demand)": "超自然語音(需要時才安裝)",
|
|
176
|
+
"Voices ready.": "語音已就緒。"
|
|
169
177
|
}
|
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,18 @@ 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 =
|
|
464
|
+
const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.locale.startsWith((voice.startsWith('ff_') ? 'fr' : 'en') + '_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped
|
|
465
|
+
.then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
|
|
451
466
|
ttsJobs.set(key, job); return job;
|
|
452
467
|
}
|
|
453
468
|
|
|
@@ -477,7 +492,7 @@ function start(port = PORT) {
|
|
|
477
492
|
const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
478
493
|
// 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
494
|
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'); }
|
|
495
|
+
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
496
|
const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
|
|
482
497
|
let body = '', over = false; req.setTimeout(10000, () => req.destroy());
|
|
483
498
|
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 +516,21 @@ function start(port = PORT) {
|
|
|
501
516
|
}
|
|
502
517
|
if (url.pathname === '/api/health') return json({ claude: findClaude(), apiKey: !!process.env.ANTHROPIC_API_KEY, trError: lastTrError, platform: process.platform });
|
|
503
518
|
if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
|
|
504
|
-
if (url.pathname === '/api/voices')
|
|
519
|
+
if (url.pathname === '/api/voices') {
|
|
520
|
+
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' }); }
|
|
521
|
+
return json(listVoices());
|
|
522
|
+
}
|
|
523
|
+
if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
|
|
524
|
+
if (url.pathname === '/api/kokoro') return json(kokoro.status());
|
|
505
525
|
if (url.pathname === '/api/tts') {
|
|
506
|
-
return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'))
|
|
526
|
+
return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
|
|
507
527
|
.then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
|
|
508
528
|
.catch((e) => json({ error: String(e.message) }, 400));
|
|
509
529
|
}
|
|
510
|
-
if (url.pathname === '/api/
|
|
530
|
+
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') + ';'); }
|
|
531
|
+
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); } });
|
|
532
|
+
if (url.pathname === '/api/prefs') return json(readPrefs() || {});
|
|
533
|
+
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
534
|
if (url.pathname === '/api/board') return json(boardRows());
|
|
512
535
|
if (url.pathname === '/api/characters') return json(listCharacters());
|
|
513
536
|
if (url.pathname === '/api/session') {
|