Spaces:
Running
Running
SamiKoen
Audit fixes (3 paralel agent bulgulari): tool zorunlulugu + audio gate + dead code + pre-filter
1661ab4 | <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> | |