BF-Realtime-V2 / static /index.html
SamiKoen
Initial: BF-Realtime V2 clean architecture
46b8062
Raw
History Blame Contribute Delete
20.2 kB
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--trek-red: #CD1F2A;
--ink: #1a1d23;
--ink-soft: #4a5260;
--ink-mute: #8b929c;
--line: rgba(0,0,0,0.08);
--line-strong: rgba(0,0,0,0.16);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f3f4f6;
color: var(--ink);
min-height: 100vh;
padding: 28px 16px 36px;
}
.stage-wrap {
width: 100%;
max-width: 1480px;
margin: 0 auto;
}
/* Ust bant — sol/sag basliklar (monitor disi, esit) */
.top-bands, .bottom-bands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.top-bands { margin-bottom: 22px; }
.bottom-bands { margin-top: 22px; }
.band {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 110px;
justify-content: center;
}
.band.bottom { min-height: 130px; }
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.brand .mark {
height: 30px;
width: auto;
filter: drop-shadow(0 4px 14px rgba(205,31,42,0.3));
}
.brand .mark path,
.brand .mark polygon { fill: var(--trek-red); }
.brand .label {
font-family: 'Rajdhani', sans-serif;
font-weight: 600;
font-size: 0.85rem;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--ink-soft);
}
h1 {
margin: 4px 0 4px;
font-family: 'Rajdhani', sans-serif;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink);
}
h1 .accent { color: var(--trek-red); }
.subtitle {
color: var(--ink-mute);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
/* Cift monitor sahnesi — buyuk monitorler, yan yana */
.stage {
display: flex;
justify-content: center;
align-items: flex-start;
flex-wrap: wrap;
gap: 40px;
}
.monitor {
width: 100%;
max-width: 720px;
position: relative;
}
.screen {
position: relative;
aspect-ratio: 16 / 10;
background: #0a0b0d;
border-radius: 10px;
padding: 14px;
box-shadow:
0 24px 50px rgba(0,0,0,0.18),
0 6px 14px rgba(0,0,0,0.10),
inset 0 0 0 1px rgba(255,255,255,0.04);
transition: box-shadow 0.3s;
}
.screen::before {
/* Kamera deligi */
content: '';
position: absolute;
top: 5px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
background: #1c1e22;
border-radius: 50%;
}
.screen-inner {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 2px;
background: #000;
position: relative;
}
.screen-inner img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.18s ease-out, filter 0.25s;
}
.screen.right .screen-inner { background: #ffffff; }
.screen.right .screen-inner img { object-fit: contain; }
.screen.right .empty {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: #ffffff;
color: var(--ink-mute);
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
padding: 0 24px;
}
/* Galeri ok butonlari — monitor icinde, sag/sol kenar */
.nav-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 3;
transition: all 0.15s;
backdrop-filter: blur(4px);
opacity: 0;
}
.screen.right:hover .nav-arrow { opacity: 1; }
.nav-arrow:hover { background: rgba(0,0,0,0.75); transform: translateY(-50%) scale(1.06); }
.nav-arrow.prev { left: 14px; }
.nav-arrow.next { right: 14px; }
.screen.right .screen-inner img { cursor: pointer; }
.screen.left.active {
box-shadow:
0 0 0 2px var(--trek-red),
0 0 30px rgba(205,31,42,0.45),
0 24px 50px rgba(0,0,0,0.18);
}
/* Stand (boyun + taban) */
.stand {
width: 38%;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
.stand .neck {
width: 35%;
height: 32px;
background: linear-gradient(180deg, #cfd2d7 0%, #a4a8af 100%);
border-radius: 0 0 6px 6px;
box-shadow: inset 0 -2px 4px rgba(0,0,0,0.12);
}
.stand .base {
width: 100%;
height: 14px;
margin-top: -2px;
background: linear-gradient(180deg, #d4d7dc 0%, #9aa0a8 100%);
border-radius: 50%;
box-shadow: 0 14px 22px rgba(0,0,0,0.16);
}
@media (max-width: 900px) {
.stage { grid-template-columns: 1fr; gap: 28px; }
}
/* Alt bant icerikleri */
.status {
padding: 7px 16px;
border-radius: 2px;
background: #ffffff;
border: 1px solid var(--line);
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 12px;
display: inline-block;
}
.status.connected { color: #0a8f4f; border-color: rgba(10,143,79,0.35); background: #ecfaf2; }
.status.connecting { color: #b67900; border-color: rgba(182,121,0,0.35); background: #fff7e6; }
.status.disconnected { color: #b91c1c; border-color: rgba(185,28,28,0.3); background: #fef2f2; }
.controls { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
button {
padding: 12px 26px;
border: none;
border-radius: 2px;
background: var(--trek-red);
color: #fff;
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 14px rgba(205,31,42,0.25);
}
button:hover:not(:disabled) {
background: #e8242f;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(205,31,42,0.4);
}
button:disabled { background: #d1d5db; color: #9aa1ac; cursor: not-allowed; box-shadow: none; }
button.danger { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); box-shadow: none; }
button.danger:hover:not(:disabled) { background: #ffffff; border-color: var(--ink-soft); }
.product-info { width: 100%; }
.product-info .pname {
font-family: 'Rajdhani', sans-serif;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink);
margin-bottom: 12px;
line-height: 1.2;
}
.product-info .plink {
display: inline-block;
padding: 9px 22px;
border: 1px solid var(--trek-red);
color: var(--trek-red);
text-decoration: none;
font-family: 'Rajdhani', sans-serif;
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.18em;
text-transform: uppercase;
border-radius: 2px;
transition: all 0.2s;
}
.product-info .plink:hover { background: var(--trek-red); color: #fff; }
.product-info .empty {
color: var(--ink-mute);
font-family: 'Rajdhani', sans-serif;
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 0.85rem;
}
.footer {
margin-top: 14px;
text-align: center;
font-size: 0.65rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--ink-mute);
}
@media (max-width: 900px) {
.top-bands, .bottom-bands { grid-template-columns: 1fr; gap: 14px; }
.band { min-height: 0; padding: 4px 0; }
.band.bottom { min-height: 0; }
h1 { font-size: 1.4rem; }
}
</style>
</head>
<body>
<div class="stage-wrap">
<!-- UST BANT (monitor disinda) — esit, sol/sag basliklar -->
<div class="top-bands">
<div class="band">
<div class="brand">
<svg class="mark" viewBox="0 0 288.1 80" xmlns="http://www.w3.org/2000/svg" aria-label="Trek">
<g>
<polygon points="238.5,22.9 214.3,22.9 201.9,57.2 226.1,57.2 "/>
<polygon points="234.1,38.8 248.4,57.2 276.3,57.2 260.3,39.1 288.1,22.9 259.7,22.9 "/>
<path d="M206.6,29.5l2.4-6.6h-61.9l-8.5,23.6c-0.5,1.5-0.6,3.2,0.5,4.5c0.4,0.4,3.1,3.6,3.7,4.2c1,1.1,2.2,2,4.2,2h49.6l2.4-6.5h-34.7c-2.1,0-3.1-1.6-2.6-3.2l1.6-4.3h38.4l2.4-6.6h-38.4l2.6-7.1C168.3,29.4,206.6,29.4,206.6,29.5z"/>
<path d="M135.5,22.9H73.9L61.5,57.2h23.6l10-27.7h17.6c1.9,0,2.6,1.4,2.2,2.7c-0.4,1.4-1,3.1-1.5,4.1c-0.6,1.4-2,2.5-4.1,2.5s-16.5,0-16.5,0L107.1,57h27.1L122,43.3c0,0,4.9,0,7.3,0c3.4,0,5.4-1.8,6.3-4.1c1-2.6,3.3-8.9,4-11C140.9,25,138.9,22.9,135.5,22.9"/>
<polygon points="69.6,22.9 2.4,22.9 0,29.5 23.2,29.5 13.2,57.2 36,57.2 46,29.5 67.3,29.5 "/>
</g>
</svg>
<span class="label" lang="en">AI Assistant</span>
</div>
<h1>Sesli <span class="accent">Asistan</span></h1>
<div class="subtitle">Bisiklet · Stok · Fiyat · Anlık Yanıt</div>
</div>
<div class="band">
<h1><span class="accent">Ürün</span> Vitrini</h1>
<div class="subtitle">Bahsedilen Ürün Burada Görüntülenir</div>
</div>
</div>
<!-- IKILI MONITOR SAHNESI -->
<div class="stage">
<div class="monitor">
<div class="screen left" id="leftScreen">
<div class="screen-inner">
<img id="avatarImg" src="/static/assistant.png" alt="Trek Asistan" />
</div>
</div>
<div class="stand"><div class="neck"></div><div class="base"></div></div>
</div>
<div class="monitor">
<div class="screen right" id="rightScreen">
<div class="screen-inner">
<div class="empty" id="productEmpty">Henüz Ürün Yok</div>
<img id="productImg" alt="" style="display:none;" />
<button class="nav-arrow prev" id="navPrev" aria-label="Önceki resim"></button>
<button class="nav-arrow next" id="navNext" aria-label="Sonraki resim"></button>
</div>
</div>
<div class="stand"><div class="neck"></div><div class="base"></div></div>
</div>
</div>
<!-- ALT BANT (monitor disinda) — esit, sol/sag bilgi -->
<div class="bottom-bands">
<div class="band bottom">
<div id="status" class="status disconnected">● Bağlantı Yok</div>
<div class="controls">
<button id="btnConnect">Konuşmaya Başla</button>
<button id="btnDisconnect" class="danger" disabled>Bitir</button>
</div>
</div>
<div class="band bottom">
<div class="product-info" id="productInfo">
<div class="empty">Ürün adı ve linki burada gösterilir</div>
</div>
</div>
</div>
<div class="footer"><span lang="en">Powered by Trek · Realtime AI</span></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 = (txt, cls) => {
$('status').textContent = '● ' + txt;
$('status').className = 'status ' + cls;
};
function visualLoop() {
requestAnimationFrame(visualLoop);
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);
$('leftScreen').classList.add('active');
$('avatarImg').style.transform = `scale(${1 + level * 0.012})`;
$('avatarImg').style.filter = `brightness(${1 + level * 0.08})`;
} else {
$('leftScreen').classList.remove('active');
$('avatarImg').style.transform = 'scale(1)';
$('avatarImg').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);
let sum = 0;
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;
sum += Math.abs(s);
}
const level = sum / chunk.length;
this.port.postMessage({ pcm: i16.buffer, level }, [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('Mikrofon izni isteniyor...', 'connecting');
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
} catch (e) {
setStatus('Mikrofon hatası: ' + e.message, 'disconnected');
$('btnConnect').disabled = false;
return;
}
setStatus('Bağlanıyor...', 'connecting');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = async () => {
setStatus('Bağlı — konuşabilirsiniz', '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;
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('Bağlantı hatası', 'disconnected');
}
let _galleryImages = [];
let _galleryIdx = 0;
let _galleryTimer = null;
let _galleryPaused = false;
function setMainImage(idx) {
if (!_galleryImages.length) return;
_galleryIdx = (idx + _galleryImages.length) % _galleryImages.length;
$('productImg').src = _galleryImages[_galleryIdx];
}
function startGalleryRotation() {
stopGalleryRotation();
if (_galleryImages.length <= 1) return;
_galleryPaused = false;
_galleryTimer = setInterval(() => {
if (!_galleryPaused) setMainImage(_galleryIdx + 1);
}, 4000);
}
function stopGalleryRotation() {
if (_galleryTimer) { clearInterval(_galleryTimer); _galleryTimer = null; }
}
function showProduct(p) {
if (!p || !p.image) return;
_galleryImages = (Array.isArray(p.images) && p.images.length) ? p.images : [p.image];
_galleryIdx = 0;
_galleryPaused = false;
const img = $('productImg');
img.onerror = () => { img.style.display = 'none'; $('productEmpty').style.display = 'flex'; };
img.onload = () => { img.style.display = 'block'; $('productEmpty').style.display = 'none'; };
img.src = _galleryImages[0];
img.alt = p.name || '';
if (_galleryImages.length > 1) startGalleryRotation();
else stopGalleryRotation();
const linkHtml = p.link ? `<a class="plink" href="${p.link}" target="_blank" rel="noopener">Ürünü Gör</a>` : '';
$('productInfo').innerHTML = `
<div class="pname">${p.name || ''}</div>
${linkHtml}
`;
}
// Ok butonlari — tiklayinca otomatik akis devam etsin
$('navPrev').onclick = () => { setMainImage(_galleryIdx - 1); startGalleryRotation(); };
$('navNext').onclick = () => { setMainImage(_galleryIdx + 1); startGalleryRotation(); };
// Resme tiklayinca duraklat / devam
$('productImg').onclick = () => { _galleryPaused = !_galleryPaused; };
function handleEvent(evt) {
switch (evt.type) {
case 'product.show':
if (evt.product) showProduct(evt.product);
break;
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;
}
setStatus('Sizi dinliyorum...', 'connected');
break;
case 'input_audio_buffer.speech_stopped':
if (!assistantSpeaking) setStatus('Düşünüyor...', 'connecting');
break;
case 'response.created':
setStatus('Yanıtlıyor', 'connected');
assistantSpeaking = true;
break;
case 'response.done':
assistantSpeaking = false;
setStatus('Bağlı — konuşabilirsiniz', 'connected');
if (evt.response?.status === 'failed')
console.error('[error]', evt.response?.status_details);
break;
case 'error':
console.error('[error]', evt.error?.message || evt);
break;
}
}
function playPCM16(i16) {
if (!playbackCtx || !analyser) return;
if (!assistantSpeaking) return;
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('Bağlantı kesildi', 'disconnected');
$('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>