diff --git a/web/zebra-audio.html b/web/zebra-audio.html index 3e44977..7d9518a 100644 --- a/web/zebra-audio.html +++ b/web/zebra-audio.html @@ -74,6 +74,7 @@

zebra audio

encrypted voice  ·  webrtc  ·  rendezvous  ·  + host your own  ·  unturf

@@ -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. +  run this for your own community →

@@ -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(); })(); + + + + + +