zebra-audio: secret TTS puppet console
Triple-click the footer integrity seal to reveal a hidden console: type a line, pick one of the cloned voices, and it is synthesized at speech.ai.unturf.com (/v1/audio/speech, no key, CORS open) and played into the call — swapped onto the outbound WebRTC track so the listener hears the voice, and to local output so you do too. The speak button doubles as stop: aborts an in-flight synthesis or halts playback, then swaps the live mic back.
This commit is contained in:
parent
162b4b2917
commit
b269fce2f6
1 changed files with 157 additions and 4 deletions
|
|
@ -74,6 +74,7 @@
|
|||
|
||||
<h1>zebra audio</h1>
|
||||
<p class="sub">encrypted voice · webrtc · rendezvous ·
|
||||
<a href="host-your-own.html">host your own</a> ·
|
||||
<a href="/">unturf</a></p>
|
||||
|
||||
<section>
|
||||
|
|
@ -104,6 +105,7 @@
|
|||
encrypted (DTLS-SRTP), peer-to-peer when the network allows, relayed through
|
||||
a TURN server otherwise. the rendezvous server only sees an opaque room id
|
||||
and code-encrypted setup data.
|
||||
<a href="host-your-own.html"><strong>run this for your own community →</strong></a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -485,13 +487,164 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
|||
}
|
||||
refreshMicList(); /* populate up front (labels fill in after mic permission) */
|
||||
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
|
||||
|
||||
/* ============================================================== *
|
||||
* puppet — hidden TTS console (triple-click the footer seal) *
|
||||
* *
|
||||
* Synthesizes typed text at speech.ai.unturf.com (OpenAI-compatible *
|
||||
* /v1/audio/speech, no key, CORS open) and plays it into the call: *
|
||||
* the decoded speech is swapped onto the outbound RTC sender so the *
|
||||
* peer hears the voice, and connected to the local output so we do *
|
||||
* too. When playback ends the live mic track is swapped back. *
|
||||
* ============================================================== */
|
||||
const TTS_BASE = (new URLSearchParams(location.search).get('tts') || 'https://speech.ai.unturf.com/v1').replace(/\/$/, '');
|
||||
let puppeting = false, puppetVoicesLoaded = false;
|
||||
let currentSource = null, currentAbort = null, stopRequested = false;
|
||||
|
||||
function setPuppet(msg, cls){
|
||||
const e = $('puppet-status'); if (!e) return;
|
||||
e.textContent = msg; e.className = 'status-line' + (cls ? ' ' + cls : '');
|
||||
$('puppet-dot').className = 'dot' + (cls === 'ok' ? ' ok' : cls === 'err' ? ' warn' : '');
|
||||
}
|
||||
async function loadPuppetVoices(){
|
||||
if (puppetVoicesLoaded) return;
|
||||
puppetVoicesLoaded = true;
|
||||
try {
|
||||
const j = await (await fetch(TTS_BASE + '/voices', { cache:'no-store' })).json();
|
||||
const voices = (j.data && j.data[0] && j.data[0].voices) || [];
|
||||
if (voices.length){
|
||||
const sel = $('puppet-voice'); sel.innerHTML = '';
|
||||
for (const name of voices){ const o = document.createElement('option'); o.value = name; o.textContent = name; sel.appendChild(o); }
|
||||
sel.value = voices.includes('aria') ? 'aria' : voices[0];
|
||||
}
|
||||
} catch(_){ /* keep the hardcoded 'aria' fallback option */ }
|
||||
}
|
||||
function revealPuppet(){
|
||||
const p = $('puppet'); if (!p) return;
|
||||
p.hidden = false;
|
||||
loadPuppetVoices();
|
||||
p.scrollIntoView({ behavior:'smooth', block:'center' });
|
||||
$('puppet-text').focus();
|
||||
}
|
||||
/* play one TTS buffer: local output always; the peer when a call is up */
|
||||
function playPuppet(audioBuf){
|
||||
return new Promise((resolve) => {
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = audioBuf;
|
||||
currentSource = src; /* so puppetStop() can halt it */
|
||||
src.connect(audioCtx.destination); /* local — we hear it too ("both") */
|
||||
const sender = pc && (pc.getSenders().find(s => s.track && s.track.kind === 'audio') || pc.getSenders()[0]);
|
||||
let restored = false;
|
||||
const restore = async () => {
|
||||
if (restored) return; restored = true;
|
||||
currentSource = null;
|
||||
const mt = micStream && micStream.getAudioTracks()[0];
|
||||
if (sender && mt){ try { await sender.replaceTrack(mt); } catch(_){} }
|
||||
resolve();
|
||||
};
|
||||
if (sender){
|
||||
const dest = audioCtx.createMediaStreamDestination();
|
||||
src.connect(dest);
|
||||
sender.replaceTrack(dest.stream.getAudioTracks()[0]).catch(()=>{});
|
||||
logLine('', 'puppet: speaking into the call');
|
||||
} else {
|
||||
logLine('', 'puppet: no call active — local playback only');
|
||||
}
|
||||
src.onended = restore;
|
||||
src.start();
|
||||
});
|
||||
}
|
||||
async function puppetSpeak(){
|
||||
if (puppeting) return;
|
||||
const text = ($('puppet-text').value || '').trim();
|
||||
if (!text){ setPuppet('type something first', 'err'); return; }
|
||||
const voice = $('puppet-voice').value || 'aria';
|
||||
puppeting = true; stopRequested = false;
|
||||
$('puppet-speak').textContent = 'stop'; /* same button now interrupts */
|
||||
setPuppet('synthesizing…', null);
|
||||
currentAbort = new AbortController();
|
||||
try {
|
||||
const res = await fetch(TTS_BASE + '/audio/speech', {
|
||||
method:'POST', headers:{ 'Content-Type':'application/json' },
|
||||
body: JSON.stringify({ input: text, voice }), signal: currentAbort.signal
|
||||
});
|
||||
if (!res.ok) throw new Error('tts http ' + res.status);
|
||||
const bytes = await res.arrayBuffer();
|
||||
if (stopRequested) throw new DOMException('stopped', 'AbortError');
|
||||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (audioCtx.state === 'suspended'){ try { await audioCtx.resume(); } catch(_){} }
|
||||
const audioBuf = await audioCtx.decodeAudioData(bytes);
|
||||
if (stopRequested) throw new DOMException('stopped', 'AbortError');
|
||||
setPuppet('speaking…', null);
|
||||
await playPuppet(audioBuf);
|
||||
if (stopRequested){ setPuppet('stopped', null); }
|
||||
else { setPuppet('spoke: "' + text.slice(0, 48) + (text.length > 48 ? '…' : '') + '"', 'ok'); $('puppet-text').value = ''; }
|
||||
} catch(e){
|
||||
if (e.name === 'AbortError' || stopRequested) setPuppet('stopped', null);
|
||||
else setPuppet('failed: ' + e.message, 'err');
|
||||
} finally {
|
||||
puppeting = false; currentSource = null; currentAbort = null;
|
||||
$('puppet-speak').textContent = 'speak';
|
||||
}
|
||||
}
|
||||
/* interrupt: abort an in-flight synthesis, or halt playback (which fires
|
||||
* onended -> restore, swapping the mic back). idempotent. */
|
||||
function puppetStop(){
|
||||
if (!puppeting) return;
|
||||
stopRequested = true;
|
||||
if (currentAbort){ try { currentAbort.abort(); } catch(_){} }
|
||||
if (currentSource){ try { currentSource.stop(); } catch(_){} }
|
||||
setPuppet('stopping…', null);
|
||||
}
|
||||
/* the seal + console live in the footer block, which the parser reaches after
|
||||
* this inline script — wire them once the rest of the document exists */
|
||||
function wirePuppet(){
|
||||
const seal = $('pi-seal');
|
||||
/* native triple-click = three clicks on the same node (event.detail === 3) */
|
||||
if (seal) seal.addEventListener('click', (e) => {
|
||||
if (e.detail === 3){ try { getSelection().removeAllRanges(); } catch(_){} revealPuppet(); }
|
||||
});
|
||||
const speak = $('puppet-speak'); if (speak) speak.addEventListener('click', () => { puppeting ? puppetStop() : puppetSpeak(); });
|
||||
const close = $('puppet-close'); if (close) close.addEventListener('click', () => { puppetStop(); $('puppet').hidden = true; });
|
||||
const tin = $('puppet-text'); if (tin) tin.addEventListener('keydown', (e) => { if (e.key === 'Enter'){ e.preventDefault(); if (!puppeting) puppetSpeak(); } });
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wirePuppet);
|
||||
else wirePuppet();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- secret: triple-click the footer "page integrity" seal -->
|
||||
<!-- ============================================================ -->
|
||||
<section id="puppet" hidden style="max-width:560px;margin:1.8rem auto 0;border:1px dashed #000;padding:0.9rem 1rem">
|
||||
<h2 style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>puppet</span>
|
||||
<button id="puppet-close" style="font-size:0.7rem;padding:0.15rem 0.6rem">close</button>
|
||||
</h2>
|
||||
<p class="note" style="margin-bottom:0.7rem">
|
||||
type a line — it is synthesized at <code>speech.ai.unturf.com</code> and spoken
|
||||
straight into the call. the listener hears the voice, and so do you.
|
||||
</p>
|
||||
<div class="row">
|
||||
<label class="note" for="puppet-voice" style="flex:0 0 auto;min-width:3rem">voice</label>
|
||||
<select id="puppet-voice"><option value="aria">aria</option></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="text" id="puppet-text" placeholder="say something in someone else's voice…" autocomplete="off">
|
||||
<button id="puppet-speak" class="invert">speak</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="dot" id="puppet-dot"></div>
|
||||
<span id="puppet-status" class="status-line">idle — not in a call yet plays locally only</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style="max-width:560px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br>
|
||||
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">1e587958aedf93270535fb510aa946e7</span><br>
|
||||
sha256 <span class="stamp-sha">ba1975bd8011a139f8dec8a868cc218e2aeb09a969831343f02564524412f726</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue