openvibers/openvibe.live Commits

Arena: lines read in the streamer's own chat TTS voice (cached), portraits drawn from their own stream frames

OP OpenVibers committed 30 Aug 2026 · 8d8fd4d59f · 7 files, +290 −36

- server/arena/voice.js + GET /api/arena/voice/:user?t=…: equipped cosmetic voice
  (else the per-identity chat voice), synthesized once per (voice, text) into
  data/tts-cache, served immutable for a week; announcer voice for headlines;
  metered into ai_usage (kind tts), daily cap + per-IP limits; browser speech
  only as the fallback. All Arena 🔊 buttons now name the speaker.
- Portraits: up to 3 real frames of the streamer (live thumb, persisted AI-moment
  frames, VOD thumbs) go to images/edits so the character is a caricature of
  their actual stream — setup, gear, lighting, silhouette; text-only generation
  is the no-frames fallback. Custom stats shape the costume.
- test/arena-voice.test.js, docs.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/docs/arena.md b/docs/arena.md
iindex 9da2bd3f..e3c96336 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -91,3 +91,8 @@ Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (b
Personas: the voice comes from their **chat history first** — their own chat lines with the room they typed in, the chat AI's profile of them as a chatter (overview, long-term memory, timeline), then the streaming profile as flavor. Output: `taunt` + three more `taunts` (ragebait in that exact typing style), `typing_style`, `spoken_as` (how people say/mishear the name — fed to name detection) and `custom_stats` (six stats unique to them, e.g. "Alt Accounts 91", drawn as their own radar on the ladder row and profile).
Tests: `node test/arena.test.js` (roster, ratings, filter, quotes) · `node test/arena-beef-board.test.js` (beefs, board, listener tick, chat commands, API) — both on a temp DB, no AI needed.
++
++## Voice + portraits
++
++- **Hear it in their voice** — every 🔊 in the Arena calls `GET /api/arena/voice/<username>?t=<line>` (`server/arena/voice.js`): the line is synthesized in the streamer's **equipped cosmetic chat voice** (else the same per-identity auto voice chat gives `user:<username>`), written once to `data/tts-cache/<sha(voice+text)>.<mp3|wav>` and served with `Cache-Control: max-age=604800` — a repeat click never re-synthesizes (server cache + browser cache). `announcer` reads headlines/lore in `arena_announcer_voice` (default `tts_default_voice`). Cloud engines are metered into `ai_usage` (kind `tts`); `arena_voice_daily_max` (2000) caps fresh syntheses per day and per-IP limits are 8/min anonymous, 30/min signed in. Browser speech is only the fallback when the engine is down.
++- **Portraits from their own frames** — `generateImage` now feeds up to 3 real frames (live thumbnail, AI-moment frames the vision job persisted, VOD thumbnails — frames whose description mentions a person first) to `images/edits`, asking for a character-select caricature that keeps their setup, gear, lighting, silhouette and the vibe of their scene; text-only generation is only the fallback for streamers with no frames yet. Custom stats and the persona shape the costume/props.
ddiff --git a/public/index.html b/public/index.html
iindex a86b566d..c3eeb9cc 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
--<script src="/js/arena.js?v=7" defer></script>
++<script src="/js/arena.js?v=9" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
ddiff --git a/public/js/arena.js b/public/js/arena.js
iindex 4d5b3e73..33d88b93 100644
---- a/public/js/arena.js
++++ b/public/js/arena.js
@@@ -49,29 +49,43 @@ function _aStopTimers() {
}
function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }
--// ── Speech: hear the taunt / quotes read out (browser TTS, no server cost) ──
++// ── Speech: hear a line in that person's OpenVibe chat voice ──
++// /api/arena/voice/<user>?t=… returns the audio synthesized once in their equipped cosmetic voice (or
++// their per-identity chat voice); the server caches the file and the browser caches the URL for a
++// week, so a repeat click never re-synthesizes. Browser speech is only the offline fallback.
++let _arenaAudio = null;
function _aStopSpeaking() {
++ try { if (_arenaAudio) { _arenaAudio.pause(); _arenaAudio.src = ''; } } catch { /* */ }
++ _arenaAudio = null;
try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }
document.querySelectorAll('.is-speaking').forEach(el => el.classList.remove('is-speaking'));
-- _arenaUtterance = null;
}
--function _aSpeak(text, btn) {
-- if (!window.speechSynthesis || !text) return;
++function _aVoiceUrl(who, text) { return `/api/arena/voice/${encodeURIComponent(who || 'announcer')}?t=${encodeURIComponent(String(text).slice(0, 240))}`; }
++function _aSpeak(text, btn, who) {
++ if (!text) return;
if (btn && btn.classList.contains('is-speaking')) { _aStopSpeaking(); return; }
_aStopSpeaking();
-- const u = new SpeechSynthesisUtterance(text);
-- u.rate = 1.05; u.pitch = 0.9;
-- const voices = speechSynthesis.getVoices();
-- const pick = voices.find(v => /en/i.test(v.lang) && /Google|Daniel|Samantha|Alex/i.test(v.name)) || voices.find(v => /en/i.test(v.lang));
-- if (pick) u.voice = pick;
-- u.onend = u.onerror = () => { if (btn) btn.classList.remove('is-speaking'); _arenaUtterance = null; };
-- _arenaUtterance = u;
if (btn) btn.classList.add('is-speaking');
-- speechSynthesis.speak(u);
++ const done = () => { if (btn) btn.classList.remove('is-speaking'); if (_arenaAudio === a) _arenaAudio = null; };
++ const a = new Audio(_aVoiceUrl(who, text));
++ _arenaAudio = a;
++ a.onended = done;
++ a.onerror = () => {
++ // Engine down / budget hit → browser voice so the button still does something.
++ if (_arenaAudio !== a) return;
++ _arenaAudio = null;
++ try {
++ if (!window.speechSynthesis) return done();
++ const u = new SpeechSynthesisUtterance(text); u.rate = 1.05; u.pitch = 0.9;
++ u.onend = u.onerror = done;
++ speechSynthesis.speak(u);
++ } catch { done(); }
++ };
++ a.play().catch(() => a.onerror && a.onerror());
}
--function _aSpeakBtn(text, cls = 'arena-speak') { return `<button type="button" class="${cls}" data-speak="${_aEsc(text)}" title="Hear it (browser voice)"><i class="fa-solid fa-volume-high"></i></button>`; }
++function _aSpeakBtn(text, cls = 'arena-speak', who = null) { return `<button type="button" class="${cls}" data-speak="${_aEsc(text)}" data-voice="${_aEsc(who || '')}" title="${who ? `Hear it in ${_aEsc(who)}'s chat voice` : 'Hear the announcer'}"><i class="fa-solid fa-volume-high"></i></button>`; }
function _aBindSpeak(root) {
-- root.querySelectorAll('[data-speak]').forEach(btn => btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _aSpeak(btn.dataset.speak, btn); }));
++ root.querySelectorAll('[data-speak]').forEach(btn => btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _aSpeak(btn.dataset.speak, btn, btn.dataset.voice || null); }));
}
/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
@@@ -534,7 +548,7 @@ function _aRenderList(fighters) {
<div class="arena-row-expand-actions">
${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}
-- ${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost')}
++ ${_aSpeakBtn(f.persona.taunt, 'btn btn-ghost', f.user.username)}
</div>
</div>
</div>`).join('');
@@@ -555,7 +569,7 @@ function _aFeedLine(e, b) {
<div class="arena-feed-body">
${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}
${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}
-- <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}${e.bonus ? ` · ${_aEsc(e.bonus)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '') : ''}</small>
++ <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}${e.bonus ? ` · ${_aEsc(e.bonus)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '', side.user.username) : ''}</small>
</div>
</div>`;
}
@@@ -666,7 +680,7 @@ async function _aRenderTopic(root, id) {
<h3><i class="fa-solid fa-keyboard"></i> Loudest in chat</h3>
${t.top_chatters?.length ? `<div class="arena-mini-list">${t.top_chatters.map((c, i) => `<div class="arena-mini-row"><span class="arena-rank">${i + 1}</span><span class="arena-chip"><span><strong>${_aEsc(c.username)}</strong></span></span><span class="arena-lvl">${c.n}</span></div>`).join('')}</div>` : '<p class="arena-note">Nobody yet. Type about it in any chat.</p>'}
</section>
-- ${t.best_lines?.length ? `<section><h3><i class="fa-solid fa-quote-left"></i> Best lines</h3>${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aEsc(l.username || 'anon')} · ${l.quality}/10</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '')}</div></div>`).join('')}</section>` : ''}
++ ${t.best_lines?.length ? `<section><h3><i class="fa-solid fa-quote-left"></i> Best lines</h3>${t.best_lines.map(l => `<div class="arena-quote"><div><q>${_aEsc(l.text)}</q><small>${_aEsc(l.username || 'anon')} · ${l.quality}/10</small></div><div class="arena-quote-actions">${_aPlay(l.vod_id, l.sec)} ${_aSpeakBtn(l.text, '', l.username)}</div></div>`).join('')}</section>` : ''}
</aside>
</div>
</div>`;
@@@ -765,7 +779,7 @@ function _aVoiceCard(f) {
${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
<div class="arena-quote">
<div><q>${_aEsc(p.text)}</q><small>${_aEsc(p.why || '')}${p.vod_id ? ` · at ${_aStamp(p.start_sec)}` : ''}</small></div>
-- <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '')}</div>
++ <div class="arena-quote-actions">${_aPlay(p.vod_id, p.start_sec)} ${_aSpeakBtn(p.text, '', f.user.username)}</div>
</div>`).join('')}</div>` : '<p class="arena-voice-empty">Quotes appear once enough lines have been transcribed.</p>'}
${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
</div>`;
@@@ -778,7 +792,7 @@ function _aLevelCard(f) {
<div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>
<span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
<div class="arena-level-nums"><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${l.topic_moments || 0}</b><small>moments</small></span><span><b>${l.topics_joined || 0}</b><small>subjects</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>
-- ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '')}</div></div>` : ''}
++ ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '', f.user.username)}</div></div>` : ''}
</div>`;
}
@@@ -826,8 +840,8 @@ async function _aRenderFighter(root, username) {
<div class="arena-move arena-move-weak"><span class="arena-move-kind">Weakness</span><b>${_aEsc(p.weakness)}</b></div>
</div>
<div class="arena-flavor">
-- <span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}” ${_aSpeakBtn(p.taunt)}</span>
-- ${(p.taunts || []).map(x => `<span><i class="fa-solid fa-fire"></i> “${_aEsc(x)}” ${_aSpeakBtn(x)}</span>`).join('')}
++ <span><i class="fa-solid fa-comment"></i> “${_aEsc(p.taunt)}” ${_aSpeakBtn(p.taunt, 'arena-speak', f.user.username)}</span>
++ ${(p.taunts || []).map(x => `<span><i class="fa-solid fa-fire"></i> “${_aEsc(x)}” ${_aSpeakBtn(x, 'arena-speak', f.user.username)}</span>`).join('')}
${p.typing_style ? `<span class="arena-note"><i class="fa-solid fa-keyboard"></i> ${_aEsc(p.typing_style)}</span>` : ''}
<span><i class="fa-solid fa-music"></i> ${_aEsc(p.entrance_music)}</span>
<span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
@@@ -902,7 +916,7 @@ function _aLightbox(f) {
<div class="arena-lightbox-text">
<h3>${_aEsc(f.persona.fighter_name)}</h3>
<p>${_aEsc(f.persona.title)}</p>
-- <p>How this was painted: the AI wrote the persona from the stream's own data, described the <em>scene</em> of the latest thumbnail (never the person), and an image model drew a fictional character from that.${f.image_model ? ` Model: <code>${_aEsc(f.image_model)}</code>.` : ''}</p>
++ <p>How this was painted: the AI wrote the persona from the stream's own data, then an image model restyled real frames from their streams — their setup, gear, lighting, silhouette — into a character-select caricature.${f.image_model ? ` Model: <code>${_aEsc(f.image_model)}</code>.` : ''}</p>
${f.image_prompt ? `<div class="arena-lightbox-prompt">${_aEsc(f.image_prompt)}</div>` : ''}
</div>
</div>`;
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex da9cbfe2..1c303c4d 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -407,17 +407,45 @@ function imageUrlFor(row) {
const base = path.basename(row.image_path);
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
}
--function latestThumbnailFor(userId) {
++function latestThumbnailFor(userId) { const r = referenceImagesFor(userId); return r.length ? r[0] : null; }
++/**
++ * Up to REF_MAX real frames of THIS streamer's streams, newest first: the live thumbnail, AI-moment
++ * frames the vision job persisted (data/ai-moments/<streamId>/<offset>.jpg), VOD thumbnails. Local
++ * paths or URLs. These are what the portrait is drawn FROM, so every fighter looks like their own
++ * stream — their setup, their lighting, their gear, their face — not a generic hero.
++ */
++const REF_MAX = 3;
++function referenceImagesFor(userId) {
++ const out = [];
++ const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };
try {
const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);
if (live) {
const thumbs = require('../media-proxy/live-thumbs');
const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
-- if (url) { const local = path.resolve('./data/live-thumbs', path.basename(url)); if (fs.existsSync(local)) return local; return url.startsWith('http') ? url : null; }
++ if (url) { const local = path.resolve('./data/live-thumbs', path.basename(url)); if (fs.existsSync(local)) push(local); else if (url.startsWith('http')) push(url); }
++ }
++ } catch { /* */ }
++ try {
++ const momentsDir = path.resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments');
++ const streams = db.all(`SELECT id FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 6`, [userId]);
++ for (const s of streams) {
++ const dir = path.join(momentsDir, String(s.id));
++ if (!fs.existsSync(dir)) continue;
++ // Prefer frames the vision model described as showing a person/face/reaction (they make better portraits).
++ const memRows = db.all(`SELECT offset_seconds, description, thumbnail_url FROM stream_memories WHERE stream_id = ? ORDER BY CASE WHEN LOWER(description) LIKE '%person%' OR LOWER(description) LIKE '%man %' OR LOWER(description) LIKE '%woman%' OR LOWER(description) LIKE '%face%' OR LOWER(description) LIKE '%wearing%' OR LOWER(description) LIKE '%headphones%' THEN 0 ELSE 1 END, id DESC LIMIT 4`, [s.id]);
++ for (const m of memRows) { const f = m.thumbnail_url && m.thumbnail_url.includes('/data/ai-moments/') ? path.join(momentsDir, ...m.thumbnail_url.split('/data/ai-moments/')[1].split('/')) : path.join(dir, `${m.offset_seconds}.jpg`); if (fs.existsSync(f)) push(f); }
++ if (out.length >= REF_MAX) break;
}
} catch { /* */ }
-- try { const v = db.get('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 1', [userId]); if (v && /^https?:\/\//i.test(v.thumbnail_url)) return v.thumbnail_url; } catch { /* */ }
-- return null;
++ try { for (const v of db.all('SELECT thumbnail_url FROM vods WHERE user_id = ? AND thumbnail_url IS NOT NULL AND is_public = 1 ORDER BY created_at DESC LIMIT 3', [userId])) { if (/^https?:\/\//i.test(v.thumbnail_url)) push(v.thumbnail_url); else { const local = path.resolve('.' + v.thumbnail_url); if (fs.existsSync(local)) push(local); } } } catch { /* */ }
++ return out;
++}
++async function loadImageBuffer(src) {
++ try {
++ if (/^https?:\/\//i.test(src)) { const r = await fetch(src, { signal: AbortSignal.timeout(15000) }); if (!r.ok) return null; return Buffer.from(await r.arrayBuffer()); }
++ return fs.readFileSync(src);
++ } catch { return null; }
}
const SCENE_SYSTEM = 'Describe this stream thumbnail as a SCENE for an illustrator in ≤ 60 words: setting, objects, lighting, colours, mood, what activity is happening. Do NOT describe any person\'s face, body, skin, hair, age, gender or identity — refer to a person only as "the host" if at all. Plain text only.';
const _imageInFlight = new Map();
@@@ -432,25 +460,40 @@ async function generateImage(userId, { force = false } = {}) {
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
if (!persona) return null;
let scene = '';
-- const thumb = latestThumbnailFor(userId);
++ const thumb = referenceImagesFor(userId).length ? null : latestThumbnailFor(userId); // scene text only for the no-frames fallback
if (thumb) { try { const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 }); scene = (d && d.text || '').trim(); } catch { scene = ''; } }
const color = entry?.user?.profile_color || '#8b5cf6';
++ const cs = Array.isArray(persona.custom_stats) ? persona.custom_stats.slice(0, 4).map(x => `${x.name} ${x.value}`).join(', ') : '';
++ // Reference frames: the portrait is drawn FROM the streamer's own stream (image edit) whenever we
++ // have frames; the text-only generation is the fallback for streamers with no frames yet.
++ const refs = [];
++ for (const src of referenceImagesFor(userId)) { const buf = await loadImageBuffer(src); if (buf && buf.length > 4000) refs.push({ src, buf }); }
const prompt = [
-- `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
-- `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).`,
++ refs.length ? `Turn the attached frames from this streamer's live stream into ONE fighting-game character-select portrait of them as "${persona.fighter_name}" — ${persona.title}. Keep what makes their stream recognisable: their setup, room, gear, lighting, clothing, silhouette, hair, headphones, camera angle, the vibe of the scene — exaggerated into a stylised caricature-hero (like a Street Fighter select screen), never a photo.` : `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
++ `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).${cs ? ` Their stats: ${cs}.` : ''}`,
entry?.raw?.category ? `Costume and props inspired by ${String(entry.raw.category).replace(/[-_]/g, ' ')} streaming.` : '',
-- scene ? `Background inspired by this scene: ${scene}` : 'Background: dark neon arena.',
++ !refs.length && scene ? `Background inspired by this scene: ${scene}` : (refs.length ? 'Background: their actual streaming environment from the frames, pushed into a dramatic arena lighting.' : 'Background: dark neon arena.'),
`Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,
-- 'Fictional character, not a real person. No text, no letters, no logos, no watermark.',
++ 'No text, no letters, no logos, no watermark, no UI overlays.',
].filter(Boolean).join(' ');
const p = llm.resolveProvider('vision');
const model = String(setting('ai_image_model', 'gpt-image-1'));
-- const body = { model, prompt, n: 1, size: '1024x1024' };
-- if (/^dall-e/i.test(model)) body.response_format = 'b64_json'; else body.quality = String(setting('ai_image_quality', 'low'));
++ const quality = String(setting('ai_image_quality', 'low'));
const started = Date.now();
let b64 = null;
try {
-- const res = await fetch(`${p.baseUrl}/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
++ let res;
++ if (refs.length && !/^dall-e/i.test(model)) {
++ // images/edits: the model sees the real frames and restyles them.
++ const fd = new FormData();
++ fd.append('model', model); fd.append('prompt', prompt); fd.append('n', '1'); fd.append('size', '1024x1024'); fd.append('quality', quality);
++ for (const r of refs) fd.append('image[]', new Blob([r.buf], { type: 'image/jpeg' }), path.basename(String(r.src)).replace(/[^a-z0-9._-]/gi, '_') || 'frame.jpg');
++ res = await fetch(`${p.baseUrl}/images/edits`, { method: 'POST', headers: { ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: fd, signal: AbortSignal.timeout(180000) });
++ } else {
++ const body = { model, prompt, n: 1, size: '1024x1024' };
++ if (/^dall-e/i.test(model)) body.response_format = 'b64_json'; else body.quality = quality;
++ res = await fetch(`${p.baseUrl}/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
++ }
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
const item = j.data && j.data[0];
@@@ -468,7 +511,7 @@ async function generateImage(userId, { force = false } = {}) {
db.run(`INSERT INTO arena_profiles (user_id, image_path, image_prompt, image_model, image_generated_at, image_error, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET image_path = excluded.image_path, image_prompt = excluded.image_prompt, image_model = excluded.image_model, image_generated_at = CURRENT_TIMESTAMP, image_error = NULL, updated_at = CURRENT_TIMESTAMP`, [userId, file, prompt, model]);
try { db.recordAiUsage({ kind: 'arena_image', model, cost_usd: Number(setting('ai_image_cost_usd', 0.011)) || 0, owner_user_id: userId, source: 'arena', role: 'image', provider: 'shared', latency_ms: Date.now() - started }); } catch { /* */ }
-- console.log(`[Arena] portrait generated for user ${userId} (${model}, ${Date.now() - started} ms)`);
++ console.log(`[Arena] portrait generated for user ${userId} (${model}, ${refs.length ? `from ${refs.length} of their own frames` : 'text only'}, ${Date.now() - started} ms)`);
return `/data/arena/${file}`;
})().finally(() => _imageInFlight.delete(userId));
_imageInFlight.set(userId, task);
ddiff --git a/server/arena/routes.js b/server/arena/routes.js
iindex 667d720a..cbe8731c 100644
---- a/server/arena/routes.js
++++ b/server/arena/routes.js
@@@ -10,6 +10,7 @@
* POST /board/bounty {username} put a bounty on a fighter
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
* GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
++ * GET /voice/:user?t=<text> the line read in that user's chat TTS voice (cached on disk + a week in the browser)
*/
'use strict';
@@@ -153,6 +154,24 @@ router.get('/beefs/:id', (req, res) => {
router.post('/beefs/:id/hype', optionalAuth, (req, res) => {
try { res.json(beef.hype(Number(req.params.id), String(req.body?.side || ''), arena.voterKeyFor(req))); } catch (err) { res.status(400).json({ error: err.message }); }
});
++// ── Hear it in their voice ──
++// GET /voice/:user?t=<text> (:user = username, or "announcer"). Synthesized once per (voice, text),
++// cached on disk and by the browser (a week); the file streams back as audio.
++router.get('/voice/:user', optionalAuth, async (req, res) => {
++ try {
++ const voice = require('./voice');
++ if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });
++ const text = voice.cleanText(req.query.t || req.query.text || '');
++ if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });
++ const who = String(req.params.user || '').toLowerCase();
++ const user = who === 'announcer' ? null : userFrom(who);
++ if (who !== 'announcer' && !user) return res.status(404).json({ error: 'No such user' });
++ const out = await voice.speak({ user, text });
++ res.set({ 'Content-Type': out.mimeType, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Arena-Voice': out.voice, 'X-Cache': out.cached ? 'HIT' : 'MISS' });
++ require('fs').createReadStream(out.path).pipe(res);
++ } catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
++});
++
router.get('/levels', (req, res) => { try { res.json({ levels: board.levelsLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
router.get('/yappers', (req, res) => { try { res.json({ yappers: board.yappersLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
ddiff --git a/server/arena/voice.js b/server/arena/voice.js
nnew file mode 100644
iindex 00000000..8deeef04
---- /dev/null
++++ b/server/arena/voice.js
@@@ -0,0 +1,118 @@
++/**
++ * voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read
++ * out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the
++ * per-identity auto voice chat already gives them (same `user:<username>` key as chat-server).
++ *
++ * Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),
++ * served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply
++ * hashes to a new file. Cloud engines are metered into ai_usage (kind 'tts'); a daily cap and a
++ * per-IP limit keep the endpoint from becoming a free TTS API.
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const db = require('../db/database');
++
++const CACHE_DIR = path.resolve(process.env.TTS_CACHE_PATH || './data/tts-cache');
++const MAX_TEXT = 240;
++const MAX_CACHE_MB = 300;
++const MAX_AGE_MS = 60 * 24 * 3600_000;
++const DAILY_MAX = () => Number(db.getSetting('arena_voice_daily_max') || 2000);
++const ANNOUNCER_VOICE = () => String(db.getSetting('arena_announcer_voice') || db.getSetting('tts_default_voice') || 'gary');
++const CLOUD_COST_PER_CHAR = { google: 16 / 1_000_000, polly: 16 / 1_000_000 };
++
++let _tts = null;
++function tts() { if (!_tts) _tts = require('../chat/tts-engine'); return _tts; }
++let _synthOverride = null; // tests
++
++function ensureDir() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch { /* */ } }
++function cleanText(text) { return String(text || '').replace(/\s+/g, ' ').replace(/[<>]/g, '').trim().slice(0, MAX_TEXT); }
++
++/** How this person sounds in chat: equipped cosmetic voice, else the derived auto voice. */
++function voiceFor(user) {
++ if (!user) return { kind: 'announcer', voiceId: ANNOUNCER_VOICE(), sig: `announcer:${ANNOUNCER_VOICE()}` };
++ try {
++ const cosmetics = require('../monetization/cosmetics');
++ const prof = cosmetics.getCosmeticProfile(user.id);
++ const itemId = prof && prof.voiceFX && prof.voiceFX.itemId;
++ if (itemId && tts().VOICE_CATALOG && tts().VOICE_CATALOG[itemId]) return { kind: 'equipped', voiceId: itemId, sig: `equipped:${itemId}` };
++ } catch { /* cosmetics optional */ }
++ const key = `user:${String(user.username).toLowerCase()}`;
++ let params = null;
++ try { params = tts().deriveUserVoiceParams ? tts().deriveUserVoiceParams(key) : null; } catch { /* */ }
++ return { kind: 'auto', identityKey: key, params, sig: `auto:${key}:${params ? [params.voice, params.pitch, params.speed, params.gap].join('/') : 'default'}` };
++}
++
++function cacheKey(sig, text) { return crypto.createHash('sha256').update(`${sig}\n${text}`).digest('hex').slice(0, 32); }
++function cachedFile(key) {
++ for (const ext of ['mp3', 'wav']) { const p = path.join(CACHE_DIR, `${key}.${ext}`); if (fs.existsSync(p)) return { path: p, mimeType: ext === 'mp3' ? 'audio/mpeg' : 'audio/wav' }; }
++ return null;
++}
++
++function todayCount() { try { return db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts' AND created_at >= date('now')`)?.n || 0; } catch { return 0; } }
++
++async function synth(voice, text, username) {
++ if (_synthOverride) return _synthOverride(voice, text, username);
++ const T = tts();
++ if (voice.kind === 'auto' && T.synthesizeUserVoice) return T.synthesizeUserVoice(text, voice.identityKey, username, MAX_TEXT);
++ return T.synthesize(text, voice.voiceId, username || 'Arena', MAX_TEXT);
++}
++
++/**
++ * Speak `text` as `user` (a users row) or the announcer (null). Returns { path, mimeType, cached, voice }.
++ * Throws with a friendly message on limits.
++ */
++async function speak({ user = null, text }) {
++ ensureDir();
++ const clean = cleanText(text);
++ if (clean.length < 2) throw new Error('Nothing to say');
++ const voice = voiceFor(user);
++ const key = cacheKey(voice.sig, clean);
++ const hit = cachedFile(key);
++ if (hit) { try { fs.utimesSync(hit.path, new Date(), new Date()); } catch { /* */ } return { ...hit, cached: true, voice: voice.sig }; }
++ if (todayCount() >= DAILY_MAX()) throw new Error('The announcer is hoarse — daily voice budget reached, try tomorrow');
++ const started = Date.now();
++ const out = await synth(voice, clean, user?.username);
++ if (!out || !out.audio) throw new Error('Voice engine unavailable');
++ const ext = /wav/i.test(out.mimeType || '') ? 'wav' : 'mp3';
++ const file = path.join(CACHE_DIR, `${key}.${ext}`);
++ const tmp = `${file}.tmp`;
++ fs.writeFileSync(tmp, Buffer.from(out.audio, 'base64'));
++ fs.renameSync(tmp, file);
++ const engine = String(out.engine || '').toLowerCase();
++ const cloud = /google/.test(engine) ? 'google' : /polly|aws|amazon/.test(engine) ? 'polly' : null;
++ try { db.recordAiUsage({ kind: 'tts', model: `${engine || 'espeak'}:${out.voiceId || voice.voiceId || 'auto'}`, input_tokens: clean.length, output_tokens: 0, cost_usd: cloud ? clean.length * CLOUD_COST_PER_CHAR[cloud] : 0, owner_user_id: user?.id || null, source: 'arena', role: 'tts', provider: engine || 'espeak', latency_ms: Date.now() - started }); } catch { /* */ }
++ purge();
++ return { path: file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg', cached: false, voice: voice.sig, engine };
++}
++
++let _lastPurge = 0;
++function purge() {
++ if (Date.now() - _lastPurge < 10 * 60 * 1000) return;
++ _lastPurge = Date.now();
++ try {
++ const files = fs.readdirSync(CACHE_DIR).filter(f => /\.(mp3|wav)$/.test(f)).map(f => { const p = path.join(CACHE_DIR, f); const st = fs.statSync(p); return { p, size: st.size, at: st.mtimeMs }; });
++ const now = Date.now();
++ let total = 0;
++ for (const f of files) { if (now - f.at > MAX_AGE_MS) { try { fs.unlinkSync(f.p); } catch { /* */ } } else total += f.size; }
++ if (total > MAX_CACHE_MB * 1024 * 1024) {
++ for (const f of files.filter(x => now - x.at <= MAX_AGE_MS).sort((a, b) => a.at - b.at)) { if (total <= MAX_CACHE_MB * 1024 * 1024 * 0.8) break; try { fs.unlinkSync(f.p); total -= f.size; } catch { /* */ } }
++ }
++ } catch { /* */ }
++}
++
++// Per-IP limiter: anonymous 8/min, signed-in 30/min (cache hits are still counted so a scraper can't loop).
++const _hits = new Map();
++function allow(ip, signedIn) {
++ const now = Date.now();
++ const lim = signedIn ? 30 : 8;
++ const arr = (_hits.get(ip) || []).filter(t => now - t < 60_000);
++ if (arr.length >= lim) { _hits.set(ip, arr); return false; }
++ arr.push(now); _hits.set(ip, arr);
++ if (_hits.size > 5000) { for (const [k, v] of _hits) if (!v.length || now - v[v.length - 1] > 60_000) _hits.delete(k); }
++ return true;
++}
++
++module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };
ddiff --git a/test/arena-voice.test.js b/test/arena-voice.test.js
nnew file mode 100644
iindex 00000000..a5bfc320
---- /dev/null
++++ b/test/arena-voice.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++// Arena voice: one synthesis per (voice, text), cached on disk; equipped cosmetic voice wins; limits.
++const assert = require('assert');
++const fs = require('fs'); const os = require('os'); const path = require('path');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-voice-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.TTS_CACHE_PATH = path.join(tmp, 'tts-cache');
++const db = require('../server/db/database'); db.initDb();
++const voice = require('../server/arena/voice');
++const uid = Number(db.createUser({ username: 'Goosely', email: 'g@x', password_hash: 'x', display_name: 'Goosely', stream_key: 'g'.repeat(32) }).lastInsertRowid);
++const user = db.getUserById(uid);
++let calls = [];
++voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, username }); return { audio: Buffer.from(`AUDIO:${text}`).toString('base64'), mimeType: 'audio/mpeg', engine: 'espeak-ng', voiceId: 'auto:gary' }; });
++
++(async () => {
++ const v = voice.voiceFor(user);
++ assert.strictEqual(v.kind, 'auto'); assert.strictEqual(v.identityKey, 'user:goosely', 'same identity key chat uses');
++ assert.strictEqual(voice.voiceFor(null).kind, 'announcer');
++ const a = await voice.speak({ user, text: ' you think your chat is loud? ' });
++ assert.strictEqual(a.cached, false); assert.strictEqual(a.mimeType, 'audio/mpeg'); assert.ok(fs.existsSync(a.path));
++ assert.strictEqual(fs.readFileSync(a.path, 'utf8'), 'AUDIO:you think your chat is loud?', 'text is normalised before synthesis');
++ const b = await voice.speak({ user, text: 'you think your chat is loud?' });
++ assert.strictEqual(b.cached, true); assert.strictEqual(b.path, a.path); assert.strictEqual(calls.length, 1, 'second click never re-synthesizes');
++ const c = await voice.speak({ user: null, text: 'you think your chat is loud?' });
++ assert.notStrictEqual(c.path, a.path, 'same text in another voice is another file'); assert.strictEqual(calls.length, 2);
++ assert.strictEqual(db.get(`SELECT COUNT(*) AS n FROM ai_usage WHERE kind = 'tts'`).n, 2, 'every synthesis is metered');
++ await assert.rejects(voice.speak({ user, text: 'x' }), /Nothing to say/);
++ db.setSetting('arena_voice_daily_max', '2');
++ await assert.rejects(voice.speak({ user, text: 'a brand new line' }), /hoarse|budget/, 'daily cap stops fresh synthesis');
++ const d = await voice.speak({ user, text: 'you think your chat is loud?' });
++ assert.strictEqual(d.cached, true, 'cache hits still work past the cap');
++ console.log('✅ voice: cached per (voice, text), metered, capped');
++
++ let ok = 0; for (let i = 0; i < 12; i++) if (voice.allow('1.2.3.4', false)) ok++;
++ assert.strictEqual(ok, 8, 'anonymous: 8 per minute'); assert.ok(voice.allow('1.2.3.4', true) === false || true);
++ let ok2 = 0; for (let i = 0; i < 40; i++) if (voice.allow('5.6.7.8', true)) ok2++;
++ assert.strictEqual(ok2, 30, 'signed in: 30 per minute');
++ console.log('✅ voice: per-IP limits');
++
++ // HTTP: streams the file with a week of caching; announcer works without a user.
++ const express = require('express'); const app = express(); app.use('/api/arena', require('../server/arena/routes'));
++ const srv = await new Promise(r => { const s = app.listen(0, () => r(s)); });
++ db.setSetting('arena_voice_daily_max', '100');
++ const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=${encodeURIComponent('you think your chat is loud?')}`);
++ assert.strictEqual(res.status, 200); assert.strictEqual(res.headers.get('content-type'), 'audio/mpeg'); assert.ok(/max-age=604800/.test(res.headers.get('cache-control'))); assert.strictEqual(res.headers.get('x-cache'), 'HIT');
++ assert.strictEqual((await res.text()), 'AUDIO:you think your chat is loud?');
++ const res2 = await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/announcer?t=${encodeURIComponent('GRIZZLY ANSWERS')}`);
++ assert.strictEqual(res2.status, 200); assert.strictEqual(res2.headers.get('x-cache'), 'MISS');
++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/nobody?t=hi`)).status, 404);
++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv.address().port}/api/arena/voice/goosely?t=`)).status, 400);
++ srv.close();
++ console.log('✅ voice: HTTP streaming + caching headers');
++ console.log('\n✅ All Arena voice tests passed');
++ process.exit(0);
++})().catch(err => { console.error(err); process.exit(1); });