beast-agent 2.2.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/preload.js CHANGED
@@ -12,7 +12,7 @@ contextBridge.exposeInMainWorld('beast', {
12
12
  openSession: (id) => ipcRenderer.invoke('sessions:open', id),
13
13
  deleteSession: (id) => ipcRenderer.invoke('sessions:delete', id),
14
14
  send: (sessionId, text) => ipcRenderer.invoke('agent:send', { sessionId, text }),
15
- interrupt: (sessionId) => ipcRenderer.invoke('agent:interrupt', sessionId),
15
+ interrupt: (sessionId, reason) => ipcRenderer.invoke('agent:interrupt', sessionId, reason),
16
16
  setModel: (sel) => ipcRenderer.invoke('model:set', sel),
17
17
  pickWorkspace: () => ipcRenderer.invoke('cwd:set'),
18
18
  openExternal: (url) => ipcRenderer.send('open-external', url),
@@ -51,8 +51,10 @@ contextBridge.exposeInMainWorld('beast', {
51
51
  secGet: () => ipcRenderer.invoke('sec:get'),
52
52
  secSet: (cfg) => ipcRenderer.invoke('sec:set', cfg),
53
53
  approvalRespond: (id, ok, always) => ipcRenderer.invoke('approval:respond', { id, ok, always }),
54
- sttTranscribe: (b64, lang) => ipcRenderer.invoke('stt:transcribe', b64, lang),
54
+ sttTranscribe: (b64, lang, priority) => ipcRenderer.invoke('stt:transcribe', b64, lang, priority),
55
55
  sttLangGet: () => ipcRenderer.invoke('stt:lang:get'),
56
+ sttStatus: () => ipcRenderer.invoke('stt:status'),
57
+ sttPrefetchNow: () => ipcRenderer.invoke('stt:prefetch'),
56
58
  sttLangSet: (lang) => ipcRenderer.invoke('stt:lang:set', lang),
57
59
  updateStatus: () => ipcRenderer.invoke('update:status'),
58
60
  updateCheck: () => ipcRenderer.invoke('update:check'),
@@ -97,7 +99,12 @@ contextBridge.exposeInMainWorld('beast', {
97
99
  browserNavigate: (url) => ipcRenderer.invoke('browser:navigate', url),
98
100
  browserCtrl: (action) => ipcRenderer.invoke('browser:ctrl', action),
99
101
  browserPhone: (on) => ipcRenderer.invoke('browser:phone', on),
102
+ browserMobileSet: (on) => ipcRenderer.invoke('browser:mobile', on),
103
+ browserDeviceSet: (key) => ipcRenderer.invoke('browser:device', key),
104
+ devServerDetect: () => ipcRenderer.invoke('devserver:detect'),
100
105
  browserSetWidth: (w) => ipcRenderer.invoke('browser:setWidth', w),
106
+ browserSetBottomInset: (px) => ipcRenderer.invoke('browser:bottomInset', px),
107
+ ttsSpeak: (text) => ipcRenderer.invoke('tts:synthesize', text),
101
108
  browserSetIgnoreMouse: (flag) => ipcRenderer.invoke('browser:setIgnoreMouse', flag),
102
109
  browserScreenshot: () => ipcRenderer.invoke('browser:screenshot'),
103
110
  cronList: () => ipcRenderer.invoke('cron:list'),
@@ -0,0 +1,633 @@
1
+ 'use strict';
2
+
3
+ /* Beast ELLER SERBEST konuşma — Hermes agent'ın browser ses döngüsü portu.
4
+ Kaynak: hermes-agent-main/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts,
5
+ use-voice-conversation.ts, lib/voice-barge-in.ts, lib/voice-stop-word.ts.
6
+
7
+ Döngü: dinle → konuşurken kelimeler CANLI önizlenir (kısmi transkript) → susunca
8
+ cümle kesilir → son transkript OTOMATİK GÖNDERİLİR → LLM yazarken mikrofon CANLI
9
+ KALIR (barge-in): konuşursan çalışan tur kesilir, sözün sıradaki tur olur.
10
+
11
+ Gürültü reddi: 800ms zemin kalibrasyonu, tetik = zemin×4.5 (tavan 0.37),
12
+ ~350ms pencerede %85 çoğunluk — TV/klavye gürültüleri geçmez.
13
+ Tık = modu ANINDA aç/kapat. "dur/yeter/goodbye" sözlü stop.
14
+ VAD: 0.10 eşik, 1250ms sessizlik = cümle sonu, 12s boş hava,
15
+ 90s tur tavanı, üst üste 3 boş tur = modu kapat. */
16
+
17
+ (function () {
18
+ /* ---------- stop komutları (voice-stop-word.ts portu + Türkçe set) ---------- */
19
+
20
+ const STOP_PHRASES = [
21
+ 'stop', 'stop listening', 'stop it', 'stop please', 'please stop', 'stop stop',
22
+ 'that is all', "that's all", 'never mind', 'nevermind',
23
+ 'end conversation', 'end the conversation', 'goodbye', 'good bye', 'bye', 'cancel',
24
+ // Türkçe stop komutları — yalnızca TÜM cümle bu ise eşleşir
25
+ 'dur', 'durur', 'dur dinleme', 'dinlemeyi durdur', 'yeter', 'yeterli',
26
+ 'boş ver', 'bos ver', 'iptal', 'iptal et', 'görüşürüz', 'gorusuruz',
27
+ 'bay bay', 'hoşça kal', 'hosca kal', 'kapat', 'kapat konuşmayı', 'sus',
28
+ ];
29
+ const ADDRESS_PREFIXES = ['hey beast', 'beast', 'eyvah', 'ok', 'okay', 'hey', 'hadi'];
30
+
31
+ function normalize(text) {
32
+ return String(text || '')
33
+ .toLowerCase()
34
+ .replace(/[.,!?;:…]+/g, ' ')
35
+ .replace(/\s+/g, ' ')
36
+ .trim();
37
+ }
38
+
39
+ function stripAddress(text) {
40
+ for (const prefix of ADDRESS_PREFIXES) {
41
+ if (text === prefix) continue; // tek başına hitap stop değildir
42
+ if (text.startsWith(prefix + ' ')) return text.slice(prefix.length + 1).trim();
43
+ }
44
+ return text;
45
+ }
46
+
47
+ /** TÜM söylevin stop komutu olduğunda true — "durdur konteyneri" yutulmaz. */
48
+ function isVoiceStopCommand(transcript) {
49
+ const normalized = normalize(transcript);
50
+ if (!normalized) return false;
51
+ const candidates = new Set([normalized, stripAddress(normalized)]);
52
+ for (const c of candidates) if (STOP_PHRASES.includes(c)) return true;
53
+ return false;
54
+ }
55
+
56
+ /* ---------- mikrofon kaydedici (use-mic-recorder.ts + barge-in portu) ---------- */
57
+
58
+ const MAX_RECORD_MS = 120000; // voice.max_recording_seconds (Hermes)
59
+ const TRIGGER_CEILING = 0.37; // ≈ int16 RMS 4000 (Hermes TRIGGER_CEILING)
60
+ const SUSTAINED_WIN = 22; // ~350ms rAF penceresi
61
+ const SUSTAINED_MAJORITY = 0.85; // %85 çoğunluk — tek kare gürültü asla geçmez
62
+
63
+ function percentile90(arr) {
64
+ if (!arr.length) return 0.02;
65
+ const sorted = [...arr].sort((a, b) => a - b);
66
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))];
67
+ }
68
+
69
+ function createMicRecorder() {
70
+ let recorder = null;
71
+ let stream = null;
72
+ let chunks = [];
73
+ let audioCtx = null;
74
+ let rafId = null;
75
+ let startedAt = 0;
76
+ let heardSpeech = false;
77
+ let triggered = false;
78
+ let silenceTriggered = false;
79
+ let silenceStartedAt = null;
80
+ let stopResolver = null;
81
+ let stopWaiters = [];
82
+
83
+ const resolveWaiters = (v) => {
84
+ const w = stopWaiters;
85
+ stopWaiters = [];
86
+ for (const f of w) { try { f(v); } catch {} }
87
+ };
88
+
89
+ const cleanup = () => {
90
+ if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
91
+ try { if (audioCtx) audioCtx.close(); } catch {}
92
+ audioCtx = null;
93
+ try { if (stream) stream.getTracks().forEach((t) => t.stop()); } catch {}
94
+ stream = null;
95
+ recorder = null;
96
+ silenceTriggered = false;
97
+ };
98
+
99
+ function startMeter(micStream, options) {
100
+ const Ctor = window.AudioContext || window.webkitAudioContext;
101
+ if (!Ctor) return;
102
+ try {
103
+ audioCtx = new Ctor();
104
+ if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
105
+ const analyser = audioCtx.createAnalyser();
106
+ analyser.fftSize = 256;
107
+ const src = audioCtx.createMediaStreamSource(micStream);
108
+ src.connect(analyser);
109
+ const data = new Uint8Array(analyser.fftSize);
110
+ const quiet = []; // (artık kullanılmıyor — EMA zemin var)
111
+ const aboveWin = []; // konuşma penceresi
112
+ const trgBuf = []; // barge kesme kararı penceresi (daha uzun, daha sıkı)
113
+ const ttsWin = []; // TTS sızıntı zemini (son ~1sn)
114
+ let floor = -1; // EMA gürültü zemini (openclaw)
115
+ let trigger = options.silenceLevel || 0;
116
+ const tick = () => {
117
+ analyser.getByteTimeDomainData(data);
118
+ let sum = 0;
119
+ for (const v of data) { const c = v - 128; sum += c * c; }
120
+ const rms = Math.sqrt(sum / data.length);
121
+ const normalized = Math.min(1, rms / 42); // Hermes: rms/42 normalize
122
+ const now = Date.now();
123
+ if (options.onLevel) options.onLevel(normalized);
124
+
125
+ const speechThreshold = options.silenceLevel || 0;
126
+ const silenceMs = options.silenceMs || 0;
127
+ const idleSilenceMs = options.idleSilenceMs || 0;
128
+
129
+ if (speechThreshold > 0 && options.onSilence && !silenceTriggered) {
130
+ /* EMA zemin (openclaw noteAudioLevel birebir):
131
+ sessizken hızlı düşer (α 0.08), sesliyken çok yavaş yükselir (α 0.01)
132
+ — sürekli uyarlanır, kalibrasyon penceresi gerekmez */
133
+ if (floor < 0) floor = Math.max(normalized, 0.03);
134
+ if (normalized < floor) floor += (normalized - floor) * 0.08;
135
+ else floor += (normalized - floor) * 0.01;
136
+
137
+ /* tetik = zemin × 5.0 (uzaktan ses de geçsin), konuşma alt sınırı ile */
138
+ trigger = Math.max(speechThreshold, Math.min(TRIGGER_CEILING, floor * 5.0));
139
+
140
+ /* TTS çalarken: hoparlör sızıntısı ayrı hızlı zemin — tetik sızıntı × 1.9;
141
+ kullanıcı erken başlarsa da yakalanır (Chrome AEC sızıntıyı dener) */
142
+ if (ttsState.playing) {
143
+ ttsWin.push(normalized);
144
+ if (ttsWin.length > 12) ttsWin.shift();
145
+ trigger = Math.max(BARGE_MIN_TRIGGER, Math.min(0.5, percentile90(ttsWin) * 1.9));
146
+ }
147
+
148
+ /* grace: submit sonrası kısa süre konuşma tetiklenmez (zemin yine güncellenir) */
149
+ const inGrace = now - startedAt < (options.graceMs || 0);
150
+
151
+ const isAbove = normalized >= trigger;
152
+ aboveWin.push(isAbove);
153
+ if (aboveWin.length > SUSTAINED_WIN) aboveWin.shift();
154
+ const majority =
155
+ aboveWin.length >= SUSTAINED_WIN &&
156
+ aboveWin.filter(Boolean).length >= Math.ceil(SUSTAINED_WIN * SUSTAINED_MAJORITY);
157
+
158
+ if (majority && !inGrace) heardSpeech = true;
159
+ if (isAbove) silenceStartedAt = null;
160
+
161
+ /* barge kesme kararı: daha uzun pencere + çoğunluk */
162
+ if (options.onTrigger && !triggered && !inGrace) {
163
+ const trgWin = Math.max(SUSTAINED_WIN, options.triggerWinFrames || SUSTAINED_WIN);
164
+ trgBuf.push(isAbove);
165
+ if (trgBuf.length > trgWin) trgBuf.shift();
166
+ const trgMajority =
167
+ trgBuf.length >= trgWin &&
168
+ trgBuf.filter(Boolean).length >= Math.ceil(trgWin * SUSTAINED_MAJORITY);
169
+ if (trgMajority) {
170
+ triggered = true;
171
+ options.onTrigger();
172
+ }
173
+ }
174
+
175
+ /* cümle sonu: konuşma bitince sessizlik süresi */
176
+ if (heardSpeech && silenceMs > 0) {
177
+ if (silenceStartedAt == null) silenceStartedAt = now;
178
+ if (now - silenceStartedAt >= silenceMs) {
179
+ silenceTriggered = true;
180
+ options.onSilence();
181
+ return;
182
+ }
183
+ } else if (!heardSpeech && idleSilenceMs > 0 && now - startedAt >= idleSilenceMs) {
184
+ silenceTriggered = true; // boş hava: hiç konuşulmadan timeout
185
+ options.onSilence();
186
+ return;
187
+ }
188
+ }
189
+ if (now - startedAt >= MAX_RECORD_MS && !silenceTriggered) {
190
+ silenceTriggered = true;
191
+ options.onSilence();
192
+ return;
193
+ }
194
+ rafId = requestAnimationFrame(tick);
195
+ };
196
+ tick();
197
+ } catch {}
198
+ }
199
+
200
+ function micError(error) {
201
+ const name = error instanceof DOMException ? error.name : '';
202
+ if (name === 'NotAllowedError' || name === 'SecurityError') return new Error('denied');
203
+ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return new Error('no-mic');
204
+ if (name === 'NotReadableError' || name === 'TrackStartError') return new Error('in-use');
205
+ return error instanceof Error ? error : new Error('start-failed');
206
+ }
207
+
208
+ async function start(options) {
209
+ if (recorder) return;
210
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
211
+ throw new Error('unsupported');
212
+ }
213
+ let micStream;
214
+ try {
215
+ micStream = await navigator.mediaDevices.getUserMedia({
216
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
217
+ });
218
+ } catch (e) {
219
+ throw micError(e);
220
+ }
221
+ const mimeType =
222
+ ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
223
+ (t) => MediaRecorder.isTypeSupported(t)
224
+ ) || '';
225
+ let rec;
226
+ try {
227
+ rec = new MediaRecorder(micStream, mimeType ? { mimeType } : undefined);
228
+ } catch (e) {
229
+ try { micStream.getTracks().forEach((t) => t.stop()); } catch {}
230
+ throw micError(e);
231
+ }
232
+ chunks = [];
233
+ stream = micStream;
234
+ recorder = rec;
235
+ heardSpeech = false;
236
+ triggered = false;
237
+ silenceTriggered = false;
238
+ silenceStartedAt = null;
239
+ startedAt = Date.now(); rec.ondataavailable = (ev) => { if (ev.data && ev.data.size > 0) chunks.push(ev.data); };
240
+ rec.onstop = () => {
241
+ const out = chunks;
242
+ const type = rec.mimeType || mimeType || 'audio/webm';
243
+ const durationMs = Date.now() - startedAt;
244
+ const spoken = heardSpeech;
245
+ chunks = [];
246
+ cleanup();
247
+ const resolve = stopResolver;
248
+ stopResolver = null;
249
+ if (resolve) resolve(out.length ? { audio: new Blob(out, { type }), durationMs, heardSpeech: spoken } : null);
250
+ };
251
+ rec.onerror = (ev) => {
252
+ const err = micError(ev && ev.error);
253
+ const resolve = stopResolver;
254
+ stopResolver = null;
255
+ cleanup();
256
+ if (options && options.onError) options.onError(err);
257
+ if (resolve) resolve(null);
258
+ };
259
+ rec.start();
260
+ startMeter(micStream, options);
261
+ }
262
+
263
+ function stop() {
264
+ return new Promise((resolve) => {
265
+ const rec = recorder;
266
+ if (!rec || rec.state === 'inactive') { cleanup(); resolve(null); return; }
267
+ stopResolver = resolve;
268
+ rec.stop();
269
+ });
270
+ }
271
+
272
+ /* stop'un BİRİÇİK yolunu bekle: onSilence stop'u, cancel veya hata */
273
+ function waitStop() {
274
+ if (!recorder) return Promise.resolve(null);
275
+ return new Promise((res) => { stopWaiters.push(res); });
276
+ }
277
+
278
+ function cancel() {
279
+ const rec = recorder;
280
+ const resolve = stopResolver;
281
+ stopResolver = null;
282
+ if (rec && rec.state !== 'inactive') {
283
+ rec.ondataavailable = null;
284
+ rec.onerror = null;
285
+ rec.onstop = null;
286
+ try { rec.stop(); } catch {}
287
+ }
288
+ cleanup();
289
+ resolveWaiters(null);
290
+ if (resolve) resolve(null);
291
+ }
292
+
293
+ /* kısmi transkript önizleme için şu ana kadarki ham blob */
294
+ function partialBlob() {
295
+ if (!chunks.length || !recorder) return null;
296
+ const type = (recorder.mimeType || 'audio/webm').split(';')[0];
297
+ return new Blob(chunks, { type });
298
+ }
299
+
300
+ return { start, stop, cancel, waitStop, partialBlob };
301
+ }
302
+
303
+ /* ---------- panel döngüsü (use-voice-conversation.ts portu) ---------- */
304
+
305
+ const SILENCE_LEVEL = 0.085; // konuşma eşiği alt sınırı (uzaktan ses de geçsin)
306
+ const BARGE_MIN_TRIGGER = 0.15; // barge kesme eşiği
307
+ const SILENCE_MS = 700; // openclaw: 700ms sessizlik = cümle sonu (hızlı tur)
308
+ const IDLE_SILENCE_MS = 12000; // boş hava timeout
309
+ const TURN_TIMEOUT_MS = 90000; // tek dinleme turu tavanı
310
+ const NO_SPEECH_LIMIT = 3; // üst üste 3 boş tur → modu kapat (Hermes)
311
+ const REPLY_WAIT_MS = 300000; // cevap bekleme tavanı
312
+ const DRIVE_MS = 500; // döngü kalp atışı
313
+ const PARTIAL_MS = 2500; // canlı önizleme aralığı
314
+
315
+ /* Tek mikrofon politikası: bir panel eller-serbeste girince diğeri kapanır */
316
+ let activePanel = null;
317
+
318
+ /* TTS YANKI KÖPRÜSÜ — renderer.js doldurur */
319
+ const ttsState = { lastText: '', playing: false, lastEndedAt: 0 };
320
+
321
+ /* transkript, az önce SESLENDİRİLEN metnin yankısı mı? (Hermes is_tts_echo)
322
+ kelime örtüşmesi ≥%65 veya birbiri içinde geçme → yankı say.
323
+ TTS az bittiysen (2sn) eşik %40'a düşer — kalıntı daha agresif atılır. */
324
+ function echoLikely(transcript) {
325
+ const spoken = normalize(ttsState.lastText || '');
326
+ if (!spoken || spoken.length < 8) return false;
327
+ const t = normalize(transcript || '');
328
+ if (!t || t.length < 4) return false;
329
+ if (spoken.includes(t) || t.includes(spoken)) return true;
330
+ const toks = (s) => s.split(/\s+/).filter((w) => w.length > 2);
331
+ const a = toks(t);
332
+ const b = new Set(toks(spoken));
333
+ if (!a.length || !b.size) return false;
334
+ let hit = 0;
335
+ for (const w of a) if (b.has(w)) hit++;
336
+ const ratio = hit / a.length;
337
+ return ratio >= (Date.now() - (ttsState.lastEndedAt || 0) < 2000 ? 0.4 : 0.65);
338
+ }
339
+
340
+ function blobToDataUrl(b) {
341
+ return new Promise((res) => {
342
+ const fr = new FileReader();
343
+ fr.onload = () => res(fr.result);
344
+ fr.readAsDataURL(b);
345
+ });
346
+ }
347
+
348
+ /**
349
+ * createPanel({ btn, isBusy, submit, transcribe, draft, interrupt, cutTts, status, statusHide, toast, t })
350
+ */
351
+ function createPanel(opts) {
352
+ const { btn, isBusy, submit, transcribe, toast } = opts;
353
+ const t = opts.t || ((k) => k);
354
+ const showDraft = opts.draft || (() => {});
355
+ const doInterrupt = opts.interrupt || (() => {});
356
+ const cutTts = opts.cutTts || (() => {});
357
+ const showStatus = opts.status || ((m) => toast(m));
358
+ const hideStatus = opts.statusHide || (() => {});
359
+
360
+ const recorder = createMicRecorder();
361
+ let state = 'idle'; // idle | listening | transcribing | thinking
362
+ let active = false; // eller-serbest modu AÇIK mı
363
+ let pendingStart = false;
364
+ let turnClosing = false;
365
+ let turnTimeout = null;
366
+ let partialTimer = null;
367
+ let partialBusy = false;
368
+ let partialSize = 0;
369
+ let partialCount = 0; // tur başına önizleme bütçesi (CPU koruması)
370
+ let sttLatencyMs = 0; // son transkript süresi — yavaşsa önizlemeler kapanır
371
+ let driveTimer = null;
372
+ let noSpeechStrikes = 0;
373
+
374
+ /* STT süresini ölç: turbo CPU'da yavaşsa canlı önizleme LÜKS kapanır,
375
+ final transkript hızlanır (birikme biter) */
376
+ const timedTranscribe = async (blob, isStale, priority) => {
377
+ const t0 = Date.now();
378
+ try {
379
+ return await transcribe(blob, isStale, priority);
380
+ } finally {
381
+ sttLatencyMs = Date.now() - t0;
382
+ }
383
+ };
384
+
385
+ const setState = (s) => {
386
+ state = s;
387
+ btn.classList.toggle('rec', s === 'listening');
388
+ btn.classList.toggle('hf', active && s !== 'listening');
389
+ };
390
+
391
+ const clearTurnTimeout = () => {
392
+ if (turnTimeout) { clearTimeout(turnTimeout); turnTimeout = null; }
393
+ };
394
+
395
+ const stopPartial = () => {
396
+ if (partialTimer) { clearInterval(partialTimer); partialTimer = null; }
397
+ partialBusy = false;
398
+ partialSize = 0;
399
+ };
400
+
401
+ /* konuşurken kelimeleri canlı göster; TTS çalarken kayıtta TTS sesi de
402
+ olduğu için önizleme atlanır (transkript çöp olur) */
403
+ const startPartial = () => {
404
+ stopPartial();
405
+ if (!opts.draft) return;
406
+ partialCount = 0;
407
+ sttLatencyMs = 0; // yeni tur — bir kez ölçüp karar ver
408
+ partialTimer = setInterval(async () => {
409
+ if (partialBusy || state !== 'listening' || !active) return;
410
+ if (ttsState.playing) return;
411
+ /* ADAPTİF: son transkript 3.5 sn'den uzun sürdüyse (turbo CPU'da yavaş)
412
+ önizlemeler kapanır — final hızlanır, kuyruk birikmez */
413
+ if (sttLatencyMs > 3500) return;
414
+ const blob = recorder.partialBlob();
415
+ if (!blob || blob.size <= partialSize + 4000) return; // yeni ses yoksa atla
416
+ partialBusy = true;
417
+ partialCount++;
418
+ try {
419
+ const text = String(
420
+ (await timedTranscribe(blob, () => state !== 'listening', 0)) || ''
421
+ ).trim();
422
+ partialSize = blob.size;
423
+ if (text && active && state === 'listening' && !echoLikely(text)) showDraft(text);
424
+ } catch {} finally {
425
+ partialBusy = false;
426
+ }
427
+ }, PARTIAL_MS);
428
+ };
429
+
430
+ const rearm = () => {
431
+ setState('idle');
432
+ hideStatus();
433
+ pendingStart = true; // drive() mikrofonu yeniden açar
434
+ };
435
+
436
+ const end = (silent) => {
437
+ active = false;
438
+ pendingStart = false;
439
+ turnClosing = false;
440
+ noSpeechStrikes = 0;
441
+ clearTurnTimeout();
442
+ stopPartial();
443
+ recorder.cancel();
444
+ if (activePanel === panel) activePanel = null;
445
+ setState('idle');
446
+ hideStatus();
447
+ if (!silent) toast(t('hf_stopped'));
448
+ };
449
+
450
+ async function waitReplyDone() {
451
+ const t0 = Date.now();
452
+ while (Date.now() - t0 < REPLY_WAIT_MS) {
453
+ await new Promise((r) => setTimeout(r, 250));
454
+ if (!isBusy()) return;
455
+ }
456
+ }
457
+
458
+ /* transkript ortak yolu: döner 'closed' | 'echo' | 'empty' | 'submitted' */
459
+ async function utteranceTurn(audio) {
460
+ let transcript = '';
461
+ try {
462
+ /* final transkript ÖNCELİKLİ (1) — önizlemelerin önüne geçer */
463
+ transcript = String((await timedTranscribe(audio, null, 1)) || '').trim();
464
+ } catch {
465
+ transcript = '';
466
+ }
467
+ if (!active) return 'closed';
468
+ /* TTS yankısı — ajanın kendi sesi; asla tur yapılmaz */
469
+ if (transcript && echoLikely(transcript)) return 'echo';
470
+ if (!transcript || transcript.replace(/\s/g, '').length < 2) {
471
+ noSpeechStrikes++;
472
+ if (noSpeechStrikes >= NO_SPEECH_LIMIT) { end(true); toast(t('hf_stopped')); return 'closed'; }
473
+ return 'empty';
474
+ }
475
+ if (isVoiceStopCommand(transcript)) {
476
+ end(true);
477
+ toast(t('hf_stopped'));
478
+ return 'closed';
479
+ }
480
+ noSpeechStrikes = 0;
481
+ setState('thinking');
482
+ showStatus(t('hf_thinking'));
483
+ await submit(transcript);
484
+ return 'submitted';
485
+ }
486
+
487
+ async function handleTurn() {
488
+ if (turnClosing || !active) return;
489
+ turnClosing = true;
490
+ clearTurnTimeout();
491
+ setState('transcribing');
492
+ showStatus(t('mic_transcribing'));
493
+ try {
494
+ const result = await recorder.stop();
495
+ if (!active) return;
496
+ stopPartial();
497
+ /* hayalet koruması: <1sn segment (gürültü/pik) asla tur yapılmaz */
498
+ if (!result || !result.heardSpeech || (result.durationMs && result.durationMs < 1000)) {
499
+ noSpeechStrikes++;
500
+ if (noSpeechStrikes >= NO_SPEECH_LIMIT) { end(true); return; }
501
+ rearm();
502
+ return;
503
+ }
504
+ const outcome = await utteranceTurn(result.audio);
505
+ if (outcome === 'closed' || !active) return;
506
+ if (outcome === 'echo') { rearm(); return; } // yankı — sessizce yeniden dinle
507
+ await thinkingPhase();
508
+ } finally {
509
+ turnClosing = false;
510
+ }
511
+ }
512
+
513
+ /* BARGE-IN: LLM yazarken mikrofon CANLI — adaptif sızıntı zemini ile.
514
+ Kullanıcı sesi sızıntıyı ezerse: TTS kesilir + konuşma yakalanır. */
515
+ async function thinkingPhase() {
516
+ while (active) {
517
+ let triggered = false;
518
+ let monitorRes = null;
519
+ const doneP = waitReplyDone();
520
+ const monP = (async () => {
521
+ await recorder.start({
522
+ silenceLevel: BARGE_MIN_TRIGGER,
523
+ silenceMs: SILENCE_MS,
524
+ idleSilenceMs: IDLE_SILENCE_MS,
525
+ graceMs: 600, // kısa grace — erken başlayan sözler kaybolmasın
526
+ triggerWinFrames: 30, // ~500ms pencerede %85 çoğunluk — TV kesintisi zor
527
+ onTrigger: () => {
528
+ triggered = true;
529
+ doInterrupt(); // çalışan turu kes — Stop düğmesiyle aynı kanal
530
+ cutTts(); // TTS ANINDA kesilir
531
+ startPartial();
532
+ },
533
+ onSilence: () => { recorder.stop().catch(() => {}); },
534
+ onError: () => { recorder.cancel(); },
535
+ });
536
+ const r = await recorder.waitStop();
537
+ stopPartial();
538
+ return r;
539
+ })();
540
+ const first = await Promise.race([
541
+ doneP.then(() => 'done'),
542
+ monP.then((r) => { monitorRes = r; return 'mon'; }),
543
+ ]);
544
+ if (!active) return;
545
+ if (first === 'done' && !triggered) {
546
+ /* çalma bitti — kullanıcı TAM O anda konuşuyor olabilir: kısa kontrol,
547
+ sonra 500ms drive beklemadan ANINDA dinlemeye dön */
548
+ if (monitorRes === null) {
549
+ monitorRes = await Promise.race([
550
+ monP.catch(() => null),
551
+ new Promise((r) => setTimeout(() => r(null), 250)),
552
+ ]);
553
+ }
554
+ if (!active) return;
555
+ if (monitorRes && monitorRes.heardSpeech) {
556
+ const outcome = await utteranceTurn(monitorRes.audio);
557
+ if (outcome === 'closed' || !active) return;
558
+ if (outcome === 'echo') { if (!isBusy()) { rearm(); void startListening(); return; } continue; }
559
+ continue;
560
+ }
561
+ recorder.cancel();
562
+ rearm();
563
+ void startListening(); // bekleme yok — mikrofon hemen dinleme modunda
564
+ return;
565
+ }
566
+ if (monitorRes === null) monitorRes = await monP.catch(() => null);
567
+ if (!active) return;
568
+ if (!monitorRes || (!monitorRes.heardSpeech && !triggered)) {
569
+ if (!isBusy()) { rearm(); return; }
570
+ continue;
571
+ }
572
+ const outcome = await utteranceTurn(monitorRes.audio);
573
+ if (outcome === 'closed' || !active) return;
574
+ if (outcome === 'echo') { if (!isBusy()) { rearm(); return; } continue; }
575
+ /* 'submitted' → döngü yeni izleme turu açar (LLM yeniden yazıyor) */
576
+ }
577
+ }
578
+
579
+ async function startListening() {
580
+ pendingStart = false;
581
+ if (!active || isBusy() || state !== 'idle') return;
582
+ try {
583
+ await recorder.start({
584
+ silenceLevel: SILENCE_LEVEL,
585
+ silenceMs: SILENCE_MS,
586
+ idleSilenceMs: IDLE_SILENCE_MS,
587
+ onError: () => {
588
+ pendingStart = false;
589
+ end(true);
590
+ toast(t('mic_denied'));
591
+ },
592
+ onSilence: () => { void handleTurn(); },
593
+ });
594
+ setState('listening');
595
+ showStatus(t('hf_listening'));
596
+ startPartial();
597
+ clearTurnTimeout();
598
+ turnTimeout = setTimeout(() => { void handleTurn(); }, TURN_TIMEOUT_MS);
599
+ } catch {
600
+ pendingStart = false;
601
+ setState('idle');
602
+ end(true);
603
+ toast(t('mic_denied'));
604
+ }
605
+ }
606
+
607
+ /* kalp atışı: panel serbest kalınca mikrofonu yeniden açar */
608
+ driveTimer = setInterval(() => {
609
+ if (active && pendingStart && state === 'idle' && !isBusy()) void startListening();
610
+ }, DRIVE_MS);
611
+
612
+ async function start() {
613
+ if (activePanel && activePanel !== panel) activePanel.end(true); // tek mikrofon
614
+ activePanel = panel;
615
+ active = true;
616
+ noSpeechStrikes = 0;
617
+ pendingStart = true;
618
+ toast(t('hf_start'));
619
+ await startListening();
620
+ }
621
+
622
+ /* TIK = ANINDA AÇ/KAPAT */
623
+ btn.addEventListener('click', () => {
624
+ if (!active) { void start(); return; }
625
+ end();
626
+ });
627
+
628
+ const panel = { end, get state() { return state; }, get active() { return active; } };
629
+ return panel;
630
+ }
631
+
632
+ window.BeastHandsFree = { createPanel, isVoiceStopCommand, createMicRecorder, ttsState };
633
+ })();