openvibers/openvibe.live Commits

TTS playback fix (user report): media-src data:, same-origin clip URLs for tests/previews, blob→data retry

OP OpenVibers committed 31 Aug 2026 · c0ede2f89e · 10 files, +96 −22

- CSP media-src gains data: — the mod voice preview plays a data:audio URL and
  was rejected with 'no supported source'.
- /api/tts/admin/test and /api/mod/tts-voice/preview now also return a
  same-origin url: the clip is stashed in the shared TTS cache and streamed
  from GET /api/tts/audio/<hash>.<ext> (strict name, traversal-proof) — plays
  under media-src 'self' even where blob:/data: audio is refused by the
  browser or a shield. Admin/mod players prefer the url, keep base64 fallback,
  and surface real errors instead of failing silently.
- Chat + broadcast TTS players retry once via a data: URL when the media
  element rejects the blob URL.

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

Changes

ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 5efdfdf4..ac4ffcb0 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -88,3 +88,7 @@ wss://openvibe.live/ws/chat?token=JWT_OR_API_TOKEN&streamId=123
| `tts` | `text`, `voice` | Server-generated TTS event |
| `tts-audio` | `url` | TTS audio file URL |
| `user_count` | `count` | User presence update |
++
++### TTS playback hardening (2026-08-31)
++
++`media-src` now includes `data:` (the mod voice preview plays a `data:audio/…` URL; without it the element dies with "no supported source"). The admin **Test Voice** (`POST /api/tts/admin/test`) and mod preview (`POST /api/mod/tts-voice/preview`) additionally return a same-origin `url` — the clip is parked in the shared TTS cache (`data/tts-cache`) and streamed from `GET /api/tts/audio/<hash>.<wav|mp3>` (strict filename, no traversal) — so playback works even where a browser or shield refuses `blob:`/`data:` audio. Chat/broadcast TTS players retry once with a `data:` URL when the element rejects the blob URL. Note: `tts-audio` WS payloads carry base64 (`audio` + `mimeType`), not a `url`.
ddiff --git a/public/index.html b/public/index.html
iindex 3bec9ce7..8501eb4b 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5041,13 +5041,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=78" defer></script>
++<script src="/js/chat.js?v=79" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
<script src="/js/onvif-dashboard.js?v=5" defer></script>
<script src="/js/dashboard.js?v=16" defer></script>
--<script src="/js/admin.js?v=7" defer></script>
++<script src="/js/admin.js?v=8" defer></script>
<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
<div id="floating-chat-widget" class="floating-chat-widget" style="display:none">
<div class="fcw-header" onmousedown="fcwStartDrag(event)">
@@@ -5100,7 +5100,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-workspace.js?v=15" defer></script>
--<script src="/js/broadcast.js?v=26" defer></script>
++<script src="/js/broadcast.js?v=27" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
ddiff --git a/public/js/admin.js b/public/js/admin.js
iindex 7e1754ce..559068e6 100644
---- a/public/js/admin.js
++++ b/public/js/admin.js
@@@ -1122,16 +1122,22 @@ async function testAdminTTSVoice() {
method: 'POST',
body: { voiceId, text: 'Hello, this is a TTS voice test from OpenVibe.Live.' },
});
-- if (result.audio && result.mimeType) {
-- const binaryStr = atob(result.audio);
-- const bytes = new Uint8Array(binaryStr.length);
-- for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
-- const blob = new Blob([bytes], { type: result.mimeType });
-- const url = URL.createObjectURL(blob);
++ if (result.url || (result.audio && result.mimeType)) {
++ // Prefer the same-origin clip URL — it plays under media-src 'self' everywhere;
++ // blob: is only the fallback for old responses.
++ let url = result.url, revoke = null;
++ if (!url) {
++ const binaryStr = atob(result.audio);
++ const bytes = new Uint8Array(binaryStr.length);
++ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
++ url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));
++ revoke = url;
++ }
const audio = new Audio(url);
audio.volume = 0.8;
-- audio.onended = () => URL.revokeObjectURL(url);
-- audio.play();
++ audio.onended = () => { if (revoke) URL.revokeObjectURL(revoke); };
++ audio.onerror = () => toast('Audio came back but the browser refused to play it — check extensions/shields', 'warning');
++ audio.play().catch(() => toast('Click anywhere on the page first, then test again (autoplay)', 'info'));
toast(`Testing: ${result.voiceName || voiceId} (${result.engine})`, 'info');
} else {
toast('No audio returned — check provider config', 'warning');
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex dedc9958..0ddaa98c 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -5377,8 +5377,11 @@ function _processBcTtsAudioQueue() {
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
const blob = new Blob([bytes], { type: msg.mimeType });
-- const url = URL.createObjectURL(blob);
++ const url = msg._forceDataUrl ? `data:${msg.mimeType};base64,${msg.audio}` : URL.createObjectURL(blob);
const audio = new Audio(url);
++ audio.onerror = () => {
++ if (!msg._forceDataUrl) { try { URL.revokeObjectURL(url); } catch { } msg._forceDataUrl = true; _bcTtsAudioPlaying = false; _bcTtsAudioQueue.unshift(msg); _processBcTtsAudioQueue(); }
++ };
const s = broadcastState.settings;
const volume = (s.ttsVolume || 800) / 1000;
// Speed = tempo (pitch preserved); pitch = independent shift (units, 100=+1oct).
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 7e322919..22b62f9b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -5112,7 +5112,7 @@ function renderAdminTtsVoice(bodyEl, kind, id, d) {
const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Playing…';
try {
const r = await api('/mod/tts-voice/preview', { method: 'POST', body: getParams() });
-- if (r.audio) { const a = new Audio(`data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }
++ if (r.url || r.audio) { const a = new Audio(r.url || `data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }
} catch (e) { toast(e.message || 'Preview failed', 'error'); }
btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-play"></i> Preview';
});
@@@ -5906,8 +5906,17 @@ function _processTTSAudioQueue() {
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
const blob = new Blob([bytes], { type: msg.mimeType });
-- const url = URL.createObjectURL(blob);
++ // A retry after the element rejected the blob URL plays from a data: URL instead
++ // (some browsers/shields block blob audio; CSP allows both).
++ const url = msg._forceDataUrl ? `data:${msg.mimeType};base64,${msg.audio}` : URL.createObjectURL(blob);
++ const _revoke = () => { if (!msg._forceDataUrl) URL.revokeObjectURL(url); };
const audio = new Audio(url);
++ audio.onerror = () => {
++ _revoke();
++ _ttsAudioPlaying = false;
++ if (!msg._forceDataUrl) { msg._forceDataUrl = true; _ttsAudioQueue.unshift(msg); }
++ _processTTSAudioQueue();
++ };
// Sound clips (!sound / soundboard) use the independent sound volume; TTS uses TTS volume.
const _isSoundClip = msg.type === 'soundboard-audio' || msg.message_type === 'channel-sound' || msg.message_type === 'soundboard';
const _volPref = _isSoundClip ? chatSettings.soundVolume : chatSettings.ttsVolume;
@@@ -5932,9 +5941,8 @@ function _processTTSAudioQueue() {
} else {
source.connect(gain).connect(ctx.destination);
}
-- const cleanup = () => { URL.revokeObjectURL(url); try { ctx.close(); } catch { } _ttsAudioPlaying = false; _processTTSAudioQueue(); };
++ const cleanup = () => { _revoke(); try { ctx.close(); } catch { } _ttsAudioPlaying = false; _processTTSAudioQueue(); };
audio.onended = cleanup;
-- audio.onerror = cleanup;
audio.play().catch((err) => {
if (err && err.name === 'NotAllowedError') { try { ctx.close(); } catch { } _handleTtsPlayBlocked(msg, url); return; }
cleanup();
@@@ -5942,11 +5950,10 @@ function _processTTSAudioQueue() {
} catch {
// Fallback to Audio.volume if Web Audio API unavailable
audio.volume = volume;
-- audio.onended = () => { URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue(); };
-- audio.onerror = () => { URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue(); };
++ audio.onended = () => { _revoke(); _ttsAudioPlaying = false; _processTTSAudioQueue(); };
audio.play().catch((err) => {
if (err && err.name === 'NotAllowedError') { _handleTtsPlayBlocked(msg, url); return; }
-- URL.revokeObjectURL(url); _ttsAudioPlaying = false; _processTTSAudioQueue();
++ _revoke(); _ttsAudioPlaying = false; _processTTSAudioQueue();
});
}
} catch {
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex 9c488f4c..7b0b5e14 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -954,7 +954,8 @@ router.post('/tts-voice/preview', permissions.requireGlobalMod, async (req, res)
voice: req.body?.voice, pitch: req.body?.pitch, speed: req.body?.speed, gap: req.body?.gap,
});
if (!result?.audio) return res.status(503).json({ error: 'TTS unavailable (espeak-ng not installed?)' });
-- res.json({ audio: result.audio, mimeType: result.mimeType || 'audio/wav' });
++ let url = null; try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) url = `/api/tts/audio/${stashed.file}`; } catch { /* */ }
++ res.json({ audio: result.audio, mimeType: result.mimeType || 'audio/wav', url });
} catch (err) {
console.error('[Mod] tts-voice preview error:', err.message);
res.status(500).json({ error: 'Preview failed' });
ddiff --git a/server/arena/voice.js b/server/arena/voice.js
iindex 8deeef04..76af7cea 100644
---- a/server/arena/voice.js
++++ b/server/arena/voice.js
@@@ -115,4 +115,22 @@ function allow(ip, signedIn) {
return true;
}
--module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };
++/** Park a one-off synthesized clip (admin test / mod preview) in the cache → safe same-origin filename. */
++function stash(audioBase64, mimeType) {
++ ensureDir();
++ const buf = Buffer.from(String(audioBase64 || ''), 'base64');
++ if (!buf.length) return null;
++ const ext = /wav/i.test(mimeType || '') ? 'wav' : 'mp3';
++ const file = `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${ext}`;
++ const full = path.join(CACHE_DIR, file);
++ if (!fs.existsSync(full)) { fs.writeFileSync(`${full}.tmp`, buf); fs.renameSync(`${full}.tmp`, full); purge(); }
++ return { file, mimeType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' };
++}
++/** Resolve a cache filename (strict shape, no traversal) → { path, mimeType } or null. */
++function cachedByName(file) {
++ if (!/^[a-f0-9]{32}\.(mp3|wav)$/.test(String(file || ''))) return null;
++ const full = path.join(CACHE_DIR, file);
++ return fs.existsSync(full) ? { path: full, mimeType: file.endsWith('.wav') ? 'audio/wav' : 'audio/mpeg' } : null;
++}
++
++module.exports = { speak, voiceFor, cacheKey, cleanText, allow, purge, stash, cachedByName, CACHE_DIR, MAX_TEXT, _setSynth: (fn) => { _synthOverride = fn; } };
ddiff --git a/server/chat/tts-routes.js b/server/chat/tts-routes.js
iindex 9868c7f0..90394cb3 100644
---- a/server/chat/tts-routes.js
++++ b/server/chat/tts-routes.js
@@@ -112,10 +112,23 @@ router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {
const { voiceId, text } = req.body;
const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
if (!result) return res.status(400).json({ error: 'Voice unavailable or TTS disabled' });
++ // Same-origin URL alongside the base64: plays under media-src 'self' even where the
++ // browser (or an overzealous shield) refuses blob:/data: audio.
++ try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) result.url = `/api/tts/audio/${stashed.file}`; } catch { /* base64 still works */ }
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
++// Cached one-off clips (admin tests, previews) — streamed same-origin so no blob:/data: is needed.
++router.get('/audio/:file', (req, res) => {
++ try {
++ const hit = require('../arena/voice').cachedByName(req.params.file);
++ if (!hit) return res.status(404).json({ error: 'No such clip' });
++ res.set({ 'Content-Type': hit.mimeType, 'Cache-Control': 'private, max-age=86400' });
++ require('fs').createReadStream(hit.path).pipe(res);
++ } catch (e) { res.status(500).json({ error: e.message }); }
++});
++
module.exports = router;
ddiff --git a/server/index.js b/server/index.js
iindex 3f477321..f1839b96 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -236,7 +236,9 @@ app.use(helmet({
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// or the browser blocks the media element.
-- mediaSrc: ["'self'", "blob:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
++ // data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
++ // rejects the element ("no supported source") even though the backend returned audio.
++ mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
frameSrc: ["'self'", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com"],
workerSrc: ["'self'", "blob:"],
scriptSrcAttr: ["'unsafe-inline'"],
ddiff --git a/test/arena-voice.test.js b/test/arena-voice.test.js
iindex a5bfc320..b97bd1b4 100644
---- a/test/arena-voice.test.js
++++ b/test/arena-voice.test.js
@@@ -50,6 +50,26 @@ voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, us
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');
++
++ // One-off clips (admin Test Voice / mod preview) get a same-origin URL from the same cache —
++ // no blob:/data: needed in the browser, and the filename is traversal-proof.
++ const st = voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav');
++ assert.ok(/^[a-f0-9]{32}\.wav$/.test(st.file) && fs.existsSync(path.join(voice.CACHE_DIR, st.file)));
++ assert.strictEqual(voice.stash(Buffer.from('RIFFfakewav').toString('base64'), 'audio/wav').file, st.file, 'same clip → same file');
++ assert.strictEqual(voice.stash('', 'audio/wav'), null);
++ assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
++ assert.strictEqual(voice.cachedByName('zz.wav'), null);
++ assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
++ const app2 = require('express')(); app2.use('/api/tts', require('../server/chat/tts-routes'));
++ const srv2 = await new Promise(r => { const x = app2.listen(0, () => r(x)); });
++ const clip = await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${st.file}`);
++ assert.strictEqual(clip.status, 200); assert.strictEqual(clip.headers.get('content-type'), 'audio/wav'); assert.strictEqual(await clip.text(), 'RIFFfakewav');
++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/nope.wav`)).status, 404);
++ // an arena voice line is reachable through the same clip route by its cache filename
++ const line = await voice.speak({ user, text: 'you think your chat is loud?' });
++ assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${path.basename(line.path)}`)).status, 200);
++ srv2.close();
++ console.log('✅ one-off clip stash + same-origin /api/tts/audio route');
console.log('\n✅ All Arena voice tests passed');
process.exit(0);
})().catch(err => { console.error(err); process.exit(1); });