BF-Realtime / static /index.html
SamiKoen
Audit fixes (3 paralel agent bulgulari): tool zorunlulugu + audio gate + dead code + pre-filter
1661ab4
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trek Sesli Asistan</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* TAM EKRAN BROWSER */
#browserStage {
position: fixed;
inset: 0;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
#browserFrame {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
user-select: none;
-webkit-user-drag: none;
}
#browserEmpty {
color: #999;
font-size: 1.2rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
/* SOL ALTTA FLOATING ASISTAN */
#assistantWidget {
position: fixed;
left: 24px;
bottom: 24px;
z-index: 10;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px 10px 10px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 999px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
transition: transform 0.18s ease;
}
#assistantWidget:hover { transform: translateY(-2px); }
#avatarBubble {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: inset 0 0 0 2px rgba(205, 31, 42, 0.2);
transition: box-shadow 0.25s, transform 0.18s;
}
#avatarBubble.active {
box-shadow: 0 0 0 3px #CD1F2A, 0 0 18px rgba(205, 31, 42, 0.5);
}
#avatarBubble img {
width: 100%; height: 100%;
object-fit: cover;
transition: transform 0.18s, filter 0.25s;
}
#statusDot {
position: absolute;
right: -2px; bottom: -2px;
width: 14px; height: 14px;
border-radius: 50%;
background: #aaa;
border: 2px solid #fff;
transition: background 0.2s;
}
#statusDot.connecting { background: #f0a020; }
#statusDot.connected { background: #1ea656; }
#statusDot.error { background: #c43c3c; }
.controls {
display: flex;
gap: 8px;
}
.controls button {
border: none;
background: #CD1F2A;
color: #fff;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
padding: 9px 16px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
text-transform: uppercase;
}
.controls button:hover:not(:disabled) { background: #e8242f; }
.controls button:active:not(:disabled) { transform: scale(0.96); }
.controls button:disabled {
background: #ddd;
color: #999;
cursor: not-allowed;
}
.controls button.danger {
background: transparent;
color: #888;
border: 1px solid #ddd;
padding: 9px 14px;
}
.controls button.danger:hover:not(:disabled) {
background: #f5f5f5;
color: #444;
}
</style>
</head>
<body>
<!-- TAM EKRAN BROWSER STREAM -->
<div id="browserStage">
<div id="browserEmpty">Sayfa yükleniyor…</div>
<img id="browserFrame" alt="" style="display:none;" />
</div>
<!-- FLOATING ASISTAN (sol alt) -->
<div id="assistantWidget">
<div id="avatarBubble">
<img id="avatarImg" src="/static/assistant.png" alt="" />
<span id="statusDot"></span>
</div>
<div class="controls">
<button id="btnConnect">Konuş</button>
<button id="btnDisconnect" class="danger" disabled>Bitir</button>
</div>
</div>
<script>
const SAMPLE_RATE = 24000;
let ws = null;
let mediaStream = null;
let audioCtx = null;
let workletNode = null;
let playbackCtx = null;
let playbackTime = 0;
let analyser = null;
let freqData = null;
let assistantSpeaking = false;
let activeAudioSources = [];
const $ = (id) => document.getElementById(id);
const setStatus = (cls) => { $('statusDot').className = cls || ''; };
function visualLoop() {
requestAnimationFrame(visualLoop);
const bubble = $('avatarBubble');
const img = $('avatarImg');
if (analyser && freqData && assistantSpeaking) {
analyser.getByteFrequencyData(freqData);
let sum = 0, n = 0;
for (let i = 4; i < 200; i++) { sum += freqData[i]; n++; }
const level = Math.min(1, (sum / n) / 180);
bubble.classList.add('active');
img.style.transform = `scale(${1 + level * 0.04})`;
img.style.filter = `brightness(${1 + level * 0.1})`;
} else {
bubble.classList.remove('active');
img.style.transform = 'scale(1)';
img.style.filter = 'brightness(1)';
}
}
const workletCode = `
class PCMProcessor extends AudioWorkletProcessor {
constructor() { super(); this._buf = []; this._target = 2400; }
process(inputs) {
const ch = inputs[0]?.[0];
if (!ch) return true;
for (let i = 0; i < ch.length; i++) this._buf.push(ch[i]);
while (this._buf.length >= this._target) {
const chunk = this._buf.splice(0, this._target);
const i16 = new Int16Array(chunk.length);
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]));
i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
this.port.postMessage({ pcm: i16.buffer }, [i16.buffer]);
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
`;
function arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000)
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
function base64ToInt16(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Int16Array(bytes.buffer);
}
async function connect() {
$('btnConnect').disabled = true;
setStatus('connecting');
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
} catch (e) {
setStatus('error');
$('btnConnect').disabled = false;
return;
}
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = async () => {
setStatus('connected');
$('btnDisconnect').disabled = false;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
const blob = new Blob([workletCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
await audioCtx.audioWorklet.addModule(url);
const src = audioCtx.createMediaStreamSource(mediaStream);
workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor');
workletNode.port.onmessage = (e) => {
if (ws?.readyState !== WebSocket.OPEN) return;
// Echo-feedback gate: asistan ses cikariyorsa gonderme. assistantSpeaking
// flag'i takilsa bile playbackTime kendiliginden bosalir (otomatik temizlenir).
const t = playbackCtx ? playbackCtx.currentTime : 0;
const stillPlaying = playbackTime > t + 0.05; // 50ms tolerans
// chunk'lar arasinda mikro-burst'leri de kapat (assistantSpeaking response.done'da reset)
if (stillPlaying || assistantSpeaking) return;
const b64 = arrayBufferToBase64(e.data.pcm);
ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: b64 }));
};
src.connect(workletNode);
playbackCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
playbackTime = playbackCtx.currentTime;
analyser = playbackCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.55;
freqData = new Uint8Array(analyser.frequencyBinCount);
analyser.connect(playbackCtx.destination);
};
ws.onmessage = (ev) => handleEvent(JSON.parse(ev.data));
ws.onclose = () => disconnect();
ws.onerror = () => setStatus('error');
}
// ----- Browser stream WS -----
let browserWs = null;
let browserReconnectTimer = null;
function showBrowserFrame(b64Jpeg) {
const img = $('browserFrame');
img.src = 'data:image/jpeg;base64,' + b64Jpeg;
img.style.display = 'block';
$('browserEmpty').style.display = 'none';
}
function _imgNormalizedCoords(img, evt) {
const rect = img.getBoundingClientRect();
const naturalRatio = 1280 / 800;
const boxRatio = rect.width / rect.height;
let drawW, drawH, padX, padY;
if (boxRatio > naturalRatio) {
drawH = rect.height;
drawW = drawH * naturalRatio;
padX = (rect.width - drawW) / 2;
padY = 0;
} else {
drawW = rect.width;
drawH = drawW / naturalRatio;
padX = 0;
padY = (rect.height - drawH) / 2;
}
const x = evt.clientX - rect.left - padX;
const y = evt.clientY - rect.top - padY;
if (x < 0 || y < 0 || x > drawW || y > drawH) return null;
return { x: x / drawW, y: y / drawH };
}
(function attachBrowserInteraction() {
const img = $('browserFrame');
img.addEventListener('click', (e) => {
if (!browserWs || browserWs.readyState !== WebSocket.OPEN) return;
const c = _imgNormalizedCoords(img, e);
if (!c) return;
browserWs.send(JSON.stringify({ type: 'click', x: c.x, y: c.y }));
});
img.addEventListener('wheel', (e) => {
if (!browserWs || browserWs.readyState !== WebSocket.OPEN) return;
e.preventDefault();
browserWs.send(JSON.stringify({ type: 'scroll', dy: Math.round(e.deltaY) }));
}, { passive: false });
})();
function connectBrowserStream() {
if (browserWs && (browserWs.readyState === WebSocket.OPEN || browserWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
browserWs = new WebSocket(`${proto}://${location.host}/browser`);
browserWs.onmessage = (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.type === 'browser.frame' && m.jpeg) showBrowserFrame(m.jpeg);
} catch {}
};
browserWs.onclose = () => {
browserWs = null;
if (browserReconnectTimer) clearTimeout(browserReconnectTimer);
browserReconnectTimer = setTimeout(connectBrowserStream, 2000);
};
browserWs.onerror = () => { try { browserWs.close(); } catch {} };
}
connectBrowserStream();
function handleEvent(evt) {
switch (evt.type) {
case 'response.audio.delta':
case 'response.output_audio.delta':
if (evt.delta) playPCM16(base64ToInt16(evt.delta));
break;
case 'input_audio_buffer.speech_started':
if (assistantSpeaking) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'response.cancel' }));
stopAllAudio();
assistantSpeaking = false;
}
break;
case 'response.created':
assistantSpeaking = true;
break;
case 'response.done':
assistantSpeaking = false;
// playbackTime reset — gate stuck-closed bug fix (kuyrukta kalan future-time'i temizle)
if (playbackCtx) playbackTime = playbackCtx.currentTime;
if (evt.response?.status === 'failed')
console.error('[error]', evt.response?.status_details);
break;
case 'error':
console.error('[error]', evt.error?.message || evt);
assistantSpeaking = false; // hata durumunda gate'i unutma
break;
}
}
function playPCM16(i16) {
if (!playbackCtx || !analyser) return;
// assistantSpeaking gate kaldirildi — response.audio.delta bazen response.created'dan
// once geliyor (race), ilk audio chunk'i sessizce kaybetmeyelim
const f32 = new Float32Array(i16.length);
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
const buf = playbackCtx.createBuffer(1, f32.length, SAMPLE_RATE);
buf.copyToChannel(f32, 0);
const src = playbackCtx.createBufferSource();
src.buffer = buf;
src.connect(analyser);
const now = playbackCtx.currentTime;
if (playbackTime < now) playbackTime = now;
src.start(playbackTime);
playbackTime += buf.duration;
activeAudioSources.push(src);
src.onended = () => { activeAudioSources = activeAudioSources.filter(s => s !== src); };
}
function stopAllAudio() {
for (const src of activeAudioSources) {
try { src.stop(); src.disconnect(); } catch {}
}
activeAudioSources = [];
if (playbackCtx) playbackTime = playbackCtx.currentTime;
}
function disconnect() {
setStatus('');
$('btnConnect').disabled = false;
$('btnDisconnect').disabled = true;
assistantSpeaking = false;
if (workletNode) { try { workletNode.disconnect(); } catch {} }
if (audioCtx) { try { audioCtx.close(); } catch {} }
if (playbackCtx) { try { playbackCtx.close(); } catch {} }
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
if (ws) { try { ws.close(); } catch {} }
ws = audioCtx = workletNode = playbackCtx = mediaStream = analyser = null;
}
$('btnConnect').onclick = connect;
$('btnDisconnect').onclick = disconnect;
visualLoop();
</script>
</body>
</html>