Spaces:
Sleeping
Sleeping
| <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> | |