PageSourceSearch

https://morfoz.ai/public/gercek-zamanli-gorusme.js

js morfoz.ai collected 2026-10-05 15:12:20 UTC 23,393 bytes, 540 lines download raw bytes

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.