1// GERÃEK ZAMANLI GÃRÃÅME â panel istemcisi (asistanla kesintisiz sesli görüÅme). 2// 3// `call-ui.js`ten (tur tabanlı görüÅme) FARKI: orada tarayıcı konuÅmayı yazıya 4// döküp metni gönderiyor, cevap seslendirilip dosya olarak geliyor. Burada 5// mikrofon SÃREKLİ akıyor, asistanın sesi parça parça geliyor ve asistan 6// konuÅurken kullanıcı konuÅmaya baÅlarsa sözü kesiliyor. 7// 8// SözleÅme sunucuda: `services/gercek_zamanli.py` (İSTEMCİ SÃZLEÅMESİ). 9// 1. `wss://â¦/api/calls/realtime`; kimlik İLK ÃERÃEVEDE 10// `{"type":"auth","token"}` â tarayıcı soketi baÅlık gönderemiyor ve 11// sunucu `?token=` sorgusunu bu uçta bilerek kabul etmiyor. 12// 2. `{"type":"baslat","assistant_id","conversation_id","lang"}` 13// 3. sunucu â `hazir` (ses biçimi + bu görüÅmenin süre tavanı) 14// 4. İKİ YÃNDE İKİLİ ÃERÃEVE: pcm16 LE, mono, 24 kHz 15// 5. `{"type":"bitir"}` â sunucu kapanıÅı ve muhasebeyi kendisi yapar 16// 17// â ROUTER'A DOÄRUDAN BAÄLANILMAZ ve ekranda model/saÄlayıcı adı geçmez. 18// 19// iOS karÅılıÄı: morfoz-mobile/ios/App/App/Native/GercekZamanliGorusme.swift â 20// iki istemcinin davranıÅı bilerek aynı; birinde düzeltilen diÄerinde de 21// düzeltilmeli (tests/test_gercek_zamanli_istemciler.py ölçüyor). 22// 23// Kullanım: openGercekZamanli({ assistantId, assistantName, conversationId }) 24 25import { api, escapeHtml, toast } from './api-client.js'; 26import { t, currentLang } from './i18n.js'; 27 28// SÃRÃM SORGUSU TAÅINIYOR: `audioWorklet.addModule` import map'ten geçmiyor ve 29// göreli çözüm sorguyu düÅürüyor â tarayıcı bayat bir iÅleyiciyle kalabilirdi 30// (grup-arama.js'teki KAPI_ISLEYICI_URL ile aynı gerekçe). 31const ISLEYICI_URL = (() => { 32 const u = new URL('./gercek-zamanli-isleyici.js', import.meta.url); 33 u.search = new URL(import.meta.url).search; 34 return u.href; 35})(); 36 37// KapanıŠkodu â metin anahtarı. Her kodun sunucuda TEK anlamı var 38// (`gercek_zamanli.KAPANIS`); birkaç sebep daha net söyleniyor. 39const KAPANIS_METNI = { 40 4401: 'call.rt.close.auth', 4402: 'call.rt.close.credit', 41 4403: 'call.rt.close.forbidden', 4404: 'call.rt.close.forbidden', 42 4408: 'call.rt.close.time', 4409: 'call.rt.close.busy', 43 4410: 'call.rt.close.idle', 4429: 'call.rt.close.rate', 44 4503: 'call.rt.close.unavailable', 45}; 46const SEBEP_METNI = { 47 'ses-suresi-doldu': 'call.rt.close.quota', 48 'zaten-acik': 'call.rt.close.open', 49}; 50 51// ~0,8 sn sıfır (24 kHz pcm16). Mikrofon kapatılırken gönderiliyor: konuÅmanın 52// bittiÄini sunucudaki ses algılaması SESSİZLİKTEN anlıyor; cümlenin ortasında 53// akıÅı kesmek turu sonsuza kadar açık bırakırdı. 54const SESSIZLIK_KUYRUGU_BAYT = 38400; 55 56const DURUM_METNI = { 57 baglaniyor: 'call.connecting', 58 dinliyor: 'call.rt.listening', 59 dusunuyor: 'call.rt.thinking', 60 bakiyor: 'call.rt.looking', 61 konusuyor: 'call.rt.speaking', 62 bitti: 'call.rt.ended', 63}; 64 65let G = null; // açık görüÅme (tek) 66let _stilEklendi = false; 67const _durumOnbellek = new Map(); // assistantId -> { d, an } 68 69/** 70 * BaÅlıkta "Gerçek zamanlı görüÅme" düÄmesi görünsün mü? Kurallar sunucuda 71 * (`gercek_zamanli.durum`). SESSİZ: uç düÅerse (eski sunucu 404) `null` â 72 * düÄme görünmez, sesli/görüntülü görüÅme eskisi gibi çalıÅır. 73 */ 74export async function gercekZamanliDurumu(assistantId, { taze = false } = {}) { 75 const c = _durumOnbellek.get(assistantId); 76 if (!taze && c && Date.now() - c.an < 30000) return c.d; 77 let d = null; 78 try { 79 d = await api.get(`/api/calls/realtime/durum?assistant_id=${encodeURIComponent(assistantId)}`); 80 } catch { d = null; } 81 _durumOnbellek.set(assistantId, { d, an: Date.now() }); 82 return d; 83} 84 85function _destekliMi() { 86 return !!(window.WebSocket && navigator.mediaDevices && navigator.mediaDevices.getUserMedia 87 && (window.AudioContext || window.webkitAudioContext) 88 && typeof AudioWorkletNode !== 'undefined'); 89} 90 91function _stiller() { 92 if (_stilEklendi) return; 93 _stilEklendi = true; 94 const s = document.createElement('style'); 95 // ZEMİN TEMADAN BAÄIMSIZ (call-ui.js ile aynı koyu mor degrade): renkler de 96 // SABİT, tema jetonu kullanmak gece modunda beyaz üstüne beyaz yazardı. 97 s.textContent = ` 98 .mzrt-overlay { position:fixed; inset:0; z-index:2400; display:flex; flex-direction:column; 99 align-items:center; justify-content:center; gap:16px; padding:16px; 100 background:radial-gradient(120% 120% at 50% 0%, #3a1c59 0%, #1c0f2e 65%, #120a1f 100%); 101 color:#fff; animation:mzrt-in .25s ease; }
102 @keyframes mzrt-in { from { opacity:0 } to { opacity:1 } } 103 .mzrt-sahne { position:relative; width:150px; height:150px; display:flex; 104 align-items:center; justify-content:center; } 105 .mzrt-halka { position:absolute; inset:0; border-radius:50%; background:rgba(255,255,255,.16); 106 transform:scale(.78); transition:transform .12s ease-out; } 107 .mzrt-yuz { position:relative; width:108px; height:108px; border-radius:50%; 108 display:flex; align-items:center; justify-content:center; font-size:44px; font-weight:800; 109 background:linear-gradient(135deg,#6B2387,#B83DA8); box-shadow:0 8px 24px rgba(0,0,0,.25); } 110 .mzrt-ad { font-size:20px; font-weight:700; display:flex; align-items:center; gap:8px; } 111 .mzrt-rozet { font-size:10px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; 112 background:rgba(245,190,60,.22); color:#ffd873; border-radius:6px; padding:2px 7px; } 113 .mzrt-durum { font-size:13px; color:rgba(255,255,255,.8); min-height:18px; text-align:center; 114 display:flex; gap:8px; align-items:center; justify-content:center; flex-wrap:wrap; } 115 .mzrt-sure { font-variant-numeric:tabular-nums; } 116 .mzrt-dokum { width:min(560px, 100%); max-height:min(260px, 34vh); overflow-y:auto; 117 display:flex; flex-direction:column; gap:8px; padding:4px 2px; } 118 .mzrt-satir { max-width:82%; font-size:13.5px; line-height:1.45; padding:8px 12px; 119 border-radius:13px; overflow-wrap:anywhere; } 120 .mzrt-satir.ben { align-self:flex-start; background:rgba(255,255,255,.22); color:#fff; } 121 .mzrt-satir.asistan { align-self:flex-end; background:#fff; color:#18102E; } 122 .mzrt-hata { font-size:13px; text-align:center; max-width:min(520px, 100%); min-height:18px; } 123 .mzrt-denetim { display:flex; gap:26px; align-items:center; margin-top:6px; } 124 .mzrt-btn { width:56px; height:56px; border-radius:50%; border:none; cursor:pointer; 125 display:flex; align-items:center; justify-content:center; color:#fff; 126 background:rgba(255,255,255,.18); transition:background .15s, transform .1s, opacity .15s; } 127 .mzrt-btn:hover:not(:disabled) { background:rgba(255,255,255,.28); } 128 .mzrt-btn:active:not(:disabled) { transform:scale(.94); } 129 .mzrt-btn:disabled { opacity:.45; cursor:default; } 130 .mzrt-btn.vurgu { background:#fff; color:#18102E; } 131 .mzrt-btn.bitir { background:#D9463E; width:68px; height:68px; } 132 .mzrt-btn.bitir:hover { background:#c03a33; } 133 @media (prefers-reduced-motion: reduce) { .mzrt-overlay { animation:none; } .mzrt-halka { transition:none; } } 134 `; 135 document.head.appendChild(s); 136} 137 138function _ikon(ad) { 139 const p = { 140 mic: '<path d="M12 2a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/>', 141 micOff: '<line x1="2" y1="2" x2="22" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" y1="19" x2="12" y2="22"/>', 142 end: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" transform="rotate(135 12 12)"/>', 143 el: '<path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', 144 }[ad]; 145 return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${p}</svg>`; 146} 147 148// ---------- Aç ---------- 149 150/** 151 * GörüÅmeyi aç. DOÄRUDAN TIKLAMA İÅLEYİCİSİNDEN çaÄrılmalı: ses baÄlamı ilk 152 * `await`TEN ÃNCE kuruluyor â Safari hareket dıÅında kurulan baÄlamı askıda 153 * baÅlatıyor ve asistanın sesi hiç duyulmuyordu. 154 */ 155export function openGercekZamanli({ assistantId, assistantName, conversationId }) { 156 if (G) return; 157 if (!_destekliMi()) { toast(t('call.rt.unsupported'), 'error'); return; } 158 if (!api.token) return; 159 _stiller(); 160 161 const Ctx = window.AudioContext || window.webkitAudioContext; 162 const g = { 163 assistantId, conversationId: conversationId || null,
164 ctx: new Ctx(), ws: null, akim: null, kaynak: null, isleyici: null, sessiz: null, 165 kaynaklar: new Set(), sonraki: 0, hzGiris: 24000, hzCikis: 24000, 166 satirlar: [], kullaniciSatiri: null, asistanSatiri: null, 167 yanitSuruyor: false, kullaniciKonusuyor: false, kesildiAn: 0, 168 mikrofonKapali: false, bagli: false, seviye: 0, bitis: 0, saat: 0, 169 kapandi: false, sebep: null, durum: 'baglaniyor', ov: null, 170 }; 171 G = g; 172 try { g.ctx.resume(); } catch { /* askıda kalırsa ilk ses parçasında yeniden denenir */ } 173 174 const ov = document.createElement('div'); 175 ov.className = 'mzrt-overlay'; 176 ov.id = 'mzrtOverlay'; 177 ov.setAttribute('role', 'dialog'); 178 ov.setAttribute('aria-modal', 'true'); 179 const ad = assistantName || t('panel.assistant'); 180 ov.innerHTML = ` 181 <div class="mzrt-sahne"><div class="mzrt-halka" id="mzrtHalka"></div> 182 <div class="mzrt-yuz">${escapeHtml((ad.trim()[0] || '?').toUpperCase())}</div></div> 183 <div class="mzrt-ad">${escapeHtml(ad)} <span class="mzrt-rozet">${t('call.rt.badge')}</span></div> 184 <div class="mzrt-durum" aria-live="polite"><span id="mzrtDurum">${t('call.connecting')}</span> 185 <span class="mzrt-sure" id="mzrtSure" title="${t('call.rt.remaining')}"></span></div> 186 <div class="mzrt-dokum" id="mzrtDokum"></div> 187 <div class="mzrt-hata" id="mzrtHata" role="status"></div> 188 <div class="mzrt-denetim"> 189 <button class="mzrt-btn" id="mzrtMik" disabled title="${t('call.rt.mute')}" 190 aria-label="${t('call.rt.mute')}">${_ikon('mic')}</button> 191 <button class="mzrt-btn bitir" id="mzrtBitir" title="${t('call.end')}" 192 aria-label="${t('call.end')}">${_ikon('end')}</button> 193 <button class="mzrt-btn" id="mzrtKes" disabled title="${t('call.rt.interrupt')}" 194 aria-label="${t('call.rt.interrupt')}">${_ikon('el')}</button> 195 </div>`; 196 document.body.appendChild(ov); 197 g.ov = ov; 198 ov.querySelector('#mzrtBitir').addEventListener('click', closeGercekZamanli); 199 ov.querySelector('#mzrtMik').addEventListener('click', () => _mikrofonDegistir(g)); 200 ov.querySelector('#mzrtKes').addEventListener('click', () => _sozunuKes(g)); 201 202 _baglan(g); 203} 204 205async function _baglan(g) { 206 // İZİN SOKETTEN ÃNCE: izin penceresi açıkken sunucuda hat açılıp saniye 207 // saymasın. 208 try { 209 g.akim = await navigator.mediaDevices.getUserMedia({ 210 audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, 211 }); 212 } catch { 213 if (g !== G) return; 214 _hataYaz(g, t('call.rt.mic_denied')); 215 _bitir(g); 216 return; 217 } 218 if (g !== G || g.kapandi) { _akimiKapat(g); return; } 219 220 const proto = location.protocol === 'https:' ? 'wss' : 'ws'; 221 const ws = new WebSocket(`${proto}://${location.host}/api/calls/realtime`); 222 ws.binaryType = 'arraybuffer'; 223 g.ws = ws; 224 ws.onopen = () => { 225 if (g !== G || g.kapandi) return; 226 ws.send(JSON.stringify({ type: 'auth', token: api.token })); 227 const ilk = { type: 'baslat', assistant_id: g.assistantId, lang: currentLang() }; 228 if (g.conversationId) ilk.conversation_id = g.conversationId; 229 ws.send(JSON.stringify(ilk)); 230 }; 231 ws.onmessage = (e) => _mesaj(g, e.data); 232 ws.onclose = (e) => _koptu(g, e.code); 233} 234 235function _gonder(g, obj) { 236 if (g.ws && g.ws.readyState === WebSocket.OPEN) g.ws.send(JSON.stringify(obj)); 237} 238 239// ---------- Sunucudan gelenler ---------- 240 241function _mesaj(g, veri) { 242 if (g !== G || g.kapandi) return; 243 if (veri instanceof ArrayBuffer) { _cal(g, veri); return; } 244 let o; 245 try { o = JSON.parse(veri); } catch { return; } 246 switch (o && o.type) { 247 case 'hazir': _hazir(g, o); break; 248 case 'dokum': _dokum(g, o.rol, String(o.metin || ''), !!o.kesin); break; 249 case 'konusma_basladi': 250 // SÃZ KESME: asistanın sesi ANINDA dursun. Ãst kaynak yanıtı kendisi 251 // iptal ediyor; burada yalnız tarayıcıda sırada bekleyen sesi atıyoruz. 252 g.kullaniciKonusuyor = true; 253 _kes(g); 254 g.yanitSuruyor = false; 255 g.asistanSatiri = null; 256 _kullaniciSatiriHazirla(g); 257 _durumYaz(g, 'dinliyor'); 258 break; 259 case 'konusma_bitti': 260 g.kullaniciKonusuyor = false;
261 if (g.durum === 'dinliyor') _durumYaz(g, 'dusunuyor'); 262 break; 263 case 'dusunuyor': 264 _durumYaz(g, 'bakiyor'); // araç turu: eÄitildiÄi bilgilere bakıyor 265 break; 266 case 'ses_bitti': 267 g.yanitSuruyor = false; 268 _sustuMu(g); 269 break; 270 case 'hata': 271 _hataYaz(g, t('call.rt.hiccup')); 272 break; 273 case 'kapandi': 274 g.sebep = o.sebep || null; 275 break; 276 default: 277 break; 278 } 279} 280 281async function _hazir(g, o) { 282 if (g.bagli) return; 283 const ses = o.ses || {}; 284 g.hzGiris = Number(ses.hz_giris) || 24000; 285 g.hzCikis = Number(ses.hz) || 24000; 286 if (Number(o.sure_sn) > 0) g.bitis = Date.now() + Number(o.sure_sn) * 1000; 287 try { 288 await g.ctx.audioWorklet.addModule(ISLEYICI_URL); 289 if (g !== G || g.kapandi) return; 290 const kaynak = g.ctx.createMediaStreamSource(g.akim); 291 const dugum = new AudioWorkletNode(g.ctx, 'mz-canli-mikrofon', { 292 numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], 293 channelCount: 1, channelCountMode: 'explicit', 294 processorOptions: { hedefHz: g.hzGiris }, 295 }); 296 // Sessiz çıkıÅ: bazı tarayıcılar hedefe baÄlı olmayan düÄümü iÅlemiyor. 297 const sessiz = g.ctx.createGain(); 298 sessiz.gain.value = 0; 299 kaynak.connect(dugum); 300 dugum.connect(sessiz); 301 sessiz.connect(g.ctx.destination); 302 dugum.port.onmessage = (e) => { 303 if (g !== G || g.kapandi) return; 304 const d = e.data || {}; 305 g.seviye = d.seviye || 0; 306 if (d.pcm && g.ws && g.ws.readyState === WebSocket.OPEN) g.ws.send(d.pcm); 307 }; 308 g.kaynak = kaynak; g.isleyici = dugum; g.sessiz = sessiz; 309 } catch { 310 if (g !== G) return; 311 _hataYaz(g, t('call.rt.audio_failed')); 312 _bitir(g); 313 return; 314 } 315 g.bagli = true; 316 const mik = g.ov.querySelector('#mzrtMik'); 317 if (mik) mik.disabled = false; 318 // Selamlama bu arada baÅladıysa "konuÅuyor"u ezme. 319 if (g.durum === 'baglaniyor') _durumYaz(g, 'dinliyor'); 320 g.saat = setInterval(() => _tik(g), 100); 321} 322 323/** Asistan sesinden bir parça: pcm16 LE â AudioBuffer, SIRAYA. */ 324function _cal(g, buf) { 325 // `bagli`YA BAKILMIYOR: selamlama `hazir`ın hemen ardından geliyor, mikrofon 326 // iÅleyicisi o sırada hâlâ yükleniyor olabilir â beklenseydi selamlamanın 327 // baÅı yutulurdu. 328 if (g.kullaniciKonusuyor) return; 329 if (Date.now() - g.kesildiAn < 600) return; // elle kesildi: yoldaki parçalar çalmasın 330 const n = buf.byteLength >> 1; 331 if (!n) return; 332 if (g.ctx.state === 'suspended') { try { g.ctx.resume(); } catch { /* sessiz */ } } 333 const ab = g.ctx.createBuffer(1, n, g.hzCikis); 334 const hedef = ab.getChannelData(0); 335 const dv = new DataView(buf); 336 for (let i = 0; i < n; i++) hedef[i] = dv.getInt16(i * 2, true) / 32768; 337 const src = g.ctx.createBufferSource(); 338 src.buffer = ab; 339 src.connect(g.ctx.destination); 340 // Parçalar arka arkaya zamanlanıyor; aÄ boÅluÄunda yeni parça 20 ms sonra 341 // baÅlıyor (geçmiÅe zamanlanan parça anında ve üst üste çalardı). 342 const bas = Math.max(g.ctx.currentTime + 0.02, g.sonraki); 343 src.start(bas); 344 g.sonraki = bas + ab.duration; 345 g.kaynaklar.add(src); 346 src.onended = () => { g.kaynaklar.delete(src); _sustuMu(g); }; 347 g.yanitSuruyor = true; 348 if (g.durum !== 'konusuyor') _durumYaz(g, 'konusuyor'); 349} 350 351/** Tarayıcıda sırada ses kalmadıysa dinlemeye dön. Router hâlâ gönderiyorsa 352 * bu iki parça arasındaki aÄ boÅluÄudur, "konuÅuyor"da kal. */ 353function _sustuMu(g) { 354 if (g !== G || g.kapandi) return; 355 if (g.kaynaklar.size === 0 && !g.yanitSuruyor && g.durum === 'konusuyor') _durumYaz(g, 'dinliyor'); 356} 357 358/** Sırada bekleyen bütün sesi AT. */ 359function _kes(g) { 360 for (const s of g.kaynaklar) { 361 s.onended = null; 362 try { s.stop(); } catch { /* baÅlamamıŠparça */ } 363 } 364 g.kaynaklar.clear(); 365 g.sonraki = 0; 366} 367 368// ---------- Döküm ---------- 369 370/** Kullanıcı konuÅmaya baÅlar baÅlamaz yer tutucu satır. Yazıya dökme 371 * konuÅma BİTTİKTEN sonra geliyor, cevap çoÄu zaman ondan önce baÅlıyor; 372 * yer tutucu olmasaydı soru cevabın ALTINA yazılırdı. */ 373function _kullaniciSatiriHazirla(g) { 374 if (g.kullaniciSatiri) return;
375 g.kullaniciSatiri = _satirEkle(g, true, 'â¦'); 376} 377 378function _satirEkle(g, ben, metin) { 379 const kutu = g.ov && g.ov.querySelector('#mzrtDokum'); 380 const el = document.createElement('div'); 381 el.className = 'mzrt-satir ' + (ben ? 'ben' : 'asistan'); 382 el.textContent = metin; 383 if (kutu) { kutu.appendChild(el); kutu.scrollTop = kutu.scrollHeight; } 384 return el; 385} 386 387function _satirYaz(g, el, metin) { 388 el.textContent = metin; 389 const kutu = g.ov && g.ov.querySelector('#mzrtDokum'); 390 if (kutu) kutu.scrollTop = kutu.scrollHeight; 391} 392 393/** Ara döküm PARÃA (eklenir), kesin döküm TAM METİN (yerine geçer). */ 394function _dokum(g, rol, metin, kesin) { 395 const tam = metin.trim(); 396 if (rol === 'user') { 397 _kullaniciSatiriHazirla(g); 398 const el = g.kullaniciSatiri; 399 if (kesin) { 400 // Gürültü yazıya dökülemediyse boÅ gelir: yer tutucu kalmasın. 401 if (!tam) el.remove(); else _satirYaz(g, el, tam); 402 g.kullaniciSatiri = null; 403 } else if (metin) { 404 _satirYaz(g, el, (el.textContent === 'â¦' ? '' : el.textContent) + metin); 405 } 406 } else if (rol === 'assistant') { 407 if (kesin) { 408 if (g.asistanSatiri) { 409 if (!tam) g.asistanSatiri.remove(); else _satirYaz(g, g.asistanSatiri, tam); 410 } else if (tam) { 411 _satirEkle(g, false, tam); 412 } 413 g.asistanSatiri = null; 414 } else if (metin) { 415 if (g.asistanSatiri) _satirYaz(g, g.asistanSatiri, g.asistanSatiri.textContent + metin); 416 else g.asistanSatiri = _satirEkle(g, false, metin); 417 } 418 } 419} 420 421// ---------- Denetimler ---------- 422 423function _mikrofonDegistir(g) { 424 if (!g.bagli || g.kapandi) return; 425 g.mikrofonKapali = !g.mikrofonKapali; 426 if (g.isleyici) g.isleyici.port.postMessage({ acik: !g.mikrofonKapali }); 427 if (g.mikrofonKapali) { 428 if (g.ws && g.ws.readyState === WebSocket.OPEN) g.ws.send(new ArrayBuffer(SESSIZLIK_KUYRUGU_BAYT)); 429 g.kullaniciKonusuyor = false; 430 } 431 const b = g.ov.querySelector('#mzrtMik'); 432 const etiket = t(g.mikrofonKapali ? 'call.rt.unmute' : 'call.rt.mute'); 433 b.classList.toggle('vurgu', g.mikrofonKapali); 434 b.innerHTML = _ikon(g.mikrofonKapali ? 'micOff' : 'mic'); 435 b.title = etiket; 436 b.setAttribute('aria-label', etiket); 437 _durumYaz(g, g.durum); 438} 439 440/** "Sözünü kes" düÄmesi â konuÅarak kesmenin YEDEÄİ (gürültülü ortam, 441 * yüksek hoparlör). `kes` YALNIZ router bu yanıtı hâlâ gönderirken gider: 442 * bitmiÅ yanıtı iptal etmek üst kaynakta hata üretiyor ve her dokunuÅ 443 * sunucunun hata kaydına düÅerdi. */ 444function _sozunuKes(g) { 445 _kes(g); 446 g.kesildiAn = Date.now(); 447 if (g.yanitSuruyor) { _gonder(g, { type: 'kes' }); g.yanitSuruyor = false; } 448 if (g.durum === 'konusuyor') _durumYaz(g, 'dinliyor'); 449} 450 451function _durumYaz(g, durum) { 452 g.durum = durum; 453 const el = g.ov && g.ov.querySelector('#mzrtDurum'); 454 if (el) { 455 el.textContent = (g.mikrofonKapali && durum === 'dinliyor') 456 ? t('call.rt.mic_off') : t(DURUM_METNI[durum] || 'call.connecting'); 457 } 458 const kes = g.ov && g.ov.querySelector('#mzrtKes'); 459 if (kes) kes.disabled = durum !== 'konusuyor'; 460} 461 462function _hataYaz(g, metin) { 463 const el = g.ov && g.ov.querySelector('#mzrtHata'); 464 if (el) el.textContent = metin || ''; 465} 466 467function _tik(g) { 468 if (g !== G || g.kapandi) return; 469 // KonuÅma RMS'i kabaca 0,02â0,25; halka bunu 0â1'e yayıyor. 470 const s = g.mikrofonKapali ? 0 : Math.min(1, g.seviye * 4); 471 const halka = g.ov.querySelector('#mzrtHalka'); 472 if (halka) halka.style.transform = `scale(${(0.78 + s * 0.32).toFixed(3)})`; 473 if (g.bitis) { 474 const k = Math.max(0, Math.ceil((g.bitis - Date.now()) / 1000)); 475 const el = g.ov.querySelector('#mzrtSure'); 476 const metin = `${Math.floor(k / 60)}:${String(k % 60).padStart(2, '0')}`; 477 if (el && el.textContent !== metin) el.textContent = metin; 478 } 479} 480 481// ---------- KapanıŠ---------- 482 483function _akimiKapat(g) { 484 try { g.akim && g.akim.getTracks().forEach(tr => tr.stop()); } catch { /* sessiz */ } 485 g.akim = null; 486} 487 488/** Ses ve soketi bırak, ekranı "bitti"de tut (kapanıŠmetni okunsun). */ 489function _bitir(g) { 490 if (g.kapandi) return; 491 g.kapandi = true; 492 if (g.saat) { clearInterval(g.saat); g.saat = 0; } 493 _kes(g); 494 try { g.kaynak && g.kaynak.disconnect(); } catch { /* sessiz */ } 495 try { g.isleyici && g.isleyici.disconnect(); } catch { /* sessiz */ } 496 try { g.sessiz && g.sessiz.disconnect(); } catch { /* sessiz */ } 497 _akimiKapat(g); 498 try { g.ctx && g.ctx.close(); } catch { /* sessiz */ } 499 if (g.ws) { 500 g.ws.onmessage = null; 501 g.ws.onclose = null; 502 try { g.ws.close(1000); } catch { /* sessiz */ } 503 } 504 g.bagli = false;
505 const mik = g.ov && g.ov.querySelector('#mzrtMik'); 506 if (mik) mik.disabled = true; 507 const halka = g.ov && g.ov.querySelector('#mzrtHalka'); 508 if (halka) halka.style.transform = 'scale(.78)'; 509 _durumYaz(g, 'bitti'); 510 _durumOnbellek.delete(g.assistantId); // kalan süre deÄiÅti 511} 512 513/** Sunucu ya da aÄ kapattı. */ 514function _koptu(g, kod) { 515 if (g !== G || g.kapandi) return; 516 const anahtar = SEBEP_METNI[g.sebep] || (kod === 1000 ? null : (KAPANIS_METNI[kod] || 'call.rt.close.lost')); 517 if (anahtar) _hataYaz(g, t(anahtar)); 518 _bitir(g); 519} 520 521/** Kullanıcı kapattı. Tekrar çaÄrılmaya DAYANIKLI. */ 522export function closeGercekZamanli() { 523 const g = G; 524 if (!g) return; 525 if (!g.kapandi && g.ws && g.ws.readyState === WebSocket.OPEN) { 526 // `bitir` ÃERÃEVESİ ÅART: sunucu onu görünce son süre ölçümünü router'dan 527 // bekleyip muhasebeyi kapatıyor. `close()` sıradaki çerçeveleri önce 528 // gönderiyor, yani hemen kapatmak güvenli. 529 try { g.ws.send(JSON.stringify({ type: 'bitir' })); } catch { /* sessiz */ } 530 } 531 _bitir(g); 532 if (g.ov) g.ov.remove(); 533 G = null; 534 // Dökümler sohbete yazıldı; açık sohbet listesini tazele (call-ui.js ile aynı). 535 try { 536 if (g.conversationId && window.morfozChat?.state?.conversationId === g.conversationId) { 537 window.morfozBumpConversation && window.morfozBumpConversation(g.conversationId, null); 538 } 539 } catch { /* sessiz */ } 540}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.