openvibers/openvibe.live Commits
Arena: lines read in the streamer's own chat TTS voice (cached), portraits drawn from their own stream frames
Changes
@@@ -91,3 +91,8 @@ Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bPersonas: 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.@@@ -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>@@@ -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>`;@@@ -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 fallbackif (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);@@@ -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'); } });nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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); });
@@ -91,3 +91,8 @@ Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (b
@@ -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).
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.
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.
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" 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/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/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -49,29 +49,43 @@ function _aStopTimers() {
@@ -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; }
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() {
function _aStopSpeaking() {
+ try { if (_arenaAudio) { _arenaAudio.pause(); _arenaAudio.src = ''; } } catch { /* */ }
+ _arenaAudio = null;
try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }
try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }
document.querySelectorAll('.is-speaking').forEach(el => el.classList.remove('is-speaking'));
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; }
if (btn && btn.classList.contains('is-speaking')) { _aStopSpeaking(); return; }
_aStopSpeaking();
_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');
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) {
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. */
/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
@@ -534,7 +548,7 @@ function _aRenderList(fighters) {
@@ -534,7 +548,7 @@ function _aRenderList(fighters) {
<div class="arena-row-expand-actions">
<div class="arena-row-expand-actions">
${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-id-card"></i> Full profile', 'btn btn-primary')}
${_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') : ''}
${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>
</div>
</div>`).join('');
</div>`).join('');
@@ -555,7 +569,7 @@ function _aFeedLine(e, b) {
@@ -555,7 +569,7 @@ function _aFeedLine(e, b) {
<div class="arena-feed-body">
<div class="arena-feed-body">
${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}
${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>` : ''}
${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>
</div>`;
</div>`;
}
}
@@ -666,7 +680,7 @@ async function _aRenderTopic(root, id) {
@@ -666,7 +680,7 @@ async function _aRenderTopic(root, id) {
<h3><i class="fa-solid fa-keyboard"></i> Loudest in chat</h3>
<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>'}
${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>
</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>
</aside>
</div>
</div>
</div>`;
</div>`;
@@ -765,7 +779,7 @@ function _aVoiceCard(f) {
@@ -765,7 +779,7 @@ function _aVoiceCard(f) {
${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
${quotes.length ? `<div class="arena-quotes">${quotes.map(p => `
<div class="arena-quote">
<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><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>'}
</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>' : ''}
${q?._fallback ? '<p class="arena-note">Quotes picked by heuristic — the AI curates these once enabled.</p>' : ''}
</div>`;
</div>`;
@@ -778,7 +792,7 @@ function _aLevelCard(f) {
@@ -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>
<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>
<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>
<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>`;
</div>`;
}
}
@@ -826,8 +840,8 @@ async function _aRenderFighter(root, username) {
@@ -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 class="arena-move arena-move-weak"><span class="arena-move-kind">Weakness</span><b>${_aEsc(p.weakness)}</b></div>
</div>
</div>
<div class="arena-flavor">
<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>` : ''}
${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-music"></i> ${_aEsc(p.entrance_music)}</span>
<span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
<span><i class="fa-solid fa-quote-left"></i> ${_aEsc(p.catchphrase)}</span>
@@ -902,7 +916,7 @@ function _aLightbox(f) {
@@ -902,7 +916,7 @@ function _aLightbox(f) {
<div class="arena-lightbox-text">
<div class="arena-lightbox-text">
<h3>${_aEsc(f.persona.fighter_name)}</h3>
<h3>${_aEsc(f.persona.fighter_name)}</h3>
<p>${_aEsc(f.persona.title)}</p>
<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>` : ''}
${f.image_prompt ? `<div class="arena-lightbox-prompt">${_aEsc(f.image_prompt)}</div>` : ''}
</div>
</div>
</div>`;
</div>`;
@@ -407,17 +407,45 @@ function imageUrlFor(row) {
@@ -407,17 +407,45 @@ function imageUrlFor(row) {
const base = path.basename(row.image_path);
const base = path.basename(row.image_path);
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
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 {
try {
const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);
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) {
if (live) {
const thumbs = require('../media-proxy/live-thumbs');
const thumbs = require('../media-proxy/live-thumbs');
const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
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 { /* */ }
} 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 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();
const _imageInFlight = new Map();
@@ -432,25 +460,40 @@ async function generateImage(userId, { force = false } = {}) {
@@ -432,25 +460,40 @@ async function generateImage(userId, { force = false } = {}) {
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
if (!persona) return null;
if (!persona) return null;
let scene = '';
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 = ''; } }
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 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 = [
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.` : '',
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.`,
`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(' ');
].filter(Boolean).join(' ');
const p = llm.resolveProvider('vision');
const p = llm.resolveProvider('vision');
const model = String(setting('ai_image_model', 'gpt-image-1'));
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();
const started = Date.now();
let b64 = null;
let b64 = null;
try {
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(() => ({}));
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
const item = j.data && j.data[0];
const item = j.data && j.data[0];
@@ -468,7 +511,7 @@ async function generateImage(userId, { force = false } = {}) {
@@ -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)
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]);
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 { /* */ }
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}`;
return `/data/arena/${file}`;
})().finally(() => _imageInFlight.delete(userId));
})().finally(() => _imageInFlight.delete(userId));
_imageInFlight.set(userId, task);
_imageInFlight.set(userId, task);
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* POST /board/bounty {username} put a bounty on a fighter
* POST /board/bounty {username} put a bounty on a fighter
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
* GET /beefs · /beefs/:id · POST /beefs/:id/hype {side}
* GET /levels · /yappers Trash Level ladder · viewers who keep subjects alive from chat
* 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';
'use strict';
@@ -153,6 +154,24 @@ router.get('/beefs/:id', (req, res) => {
@@ -153,6 +154,24 @@ router.get('/beefs/:id', (req, res) => {
router.post('/beefs/:id/hype', optionalAuth, (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 }); }
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('/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'); } });
router.get('/yappers', (req, res) => { try { res.json({ yappers: board.yappersLeaderboard(20) }); } catch (err) { fail(res, err, 'Failed'); } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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); });