beast-agent 2.44.12 → 2.44.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beast-agent",
3
3
  "productName": "Beast Agent",
4
- "version": "2.44.12",
4
+ "version": "2.44.14",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
@@ -195,6 +195,29 @@ async function install(voiceId) {
195
195
  }
196
196
  }
197
197
 
198
+ /* ---------- sentez ayarları (hız/duraklama/ifade) ----------
199
+ speed: 1.0 normal; >1 hızlı, <1 yavaş → CLI'da --length_scale 1/speed
200
+ (piper'da length_scale büyükse konuşma YAVAŞLAR)
201
+ silence: cümle sonu duraklama sn (--sentence_silence, varsayılan 0.2)
202
+ noiseScale: ifade/doğallık (--noise_scale, varsayılan 0.667; düşük=daha
203
+ stabil/robotik, yüksek=daha canlı ama artefakt riski) */
204
+ function clampNum(n, lo, hi) {
205
+ return Math.max(lo, Math.min(hi, n));
206
+ }
207
+
208
+ function tuningArgs(opts = {}) {
209
+ const args = [];
210
+ const speed = Number(opts.speed);
211
+ if (Number.isFinite(speed) && Math.abs(speed - 1) > 0.001) {
212
+ args.push('--length_scale', String(+(1 / clampNum(speed, 0.5, 2)).toFixed(3)));
213
+ }
214
+ const sil = Number(opts.sentenceSilence);
215
+ if (Number.isFinite(sil)) args.push('--sentence_silence', String(+(clampNum(sil, 0, 2)).toFixed(3)));
216
+ const noise = Number(opts.noiseScale);
217
+ if (Number.isFinite(noise)) args.push('--noise_scale', String(+(clampNum(noise, 0, 1.5)).toFixed(3)));
218
+ return args;
219
+ }
220
+
198
221
  /* ---------- sentez: metin → { audio: WAV buffer, mime } ----------
199
222
  DİKKAT: piper'ı ASLA stdout'a yazdırmıyoruz (--output_file -). Windows'ta
200
223
  binary stdout akışı bozuluyor — çıktı gürültülü/cızırtılı geliyor
@@ -215,6 +238,7 @@ function synthesize(text, opts = {}) {
215
238
  '--model', voicePath(vid),
216
239
  '--config', voiceJsonPath(vid),
217
240
  '--output_file', outFile,
241
+ ...tuningArgs(opts),
218
242
  '-q',
219
243
  ];
220
244
  const p = spawn(exePath(), args, { windowsHide: true, cwd: binDir() });
@@ -250,6 +274,7 @@ module.exports = {
250
274
  synthesize,
251
275
  install,
252
276
  status,
277
+ tuningArgs,
253
278
  ensureRuntime,
254
279
  ensureVoice,
255
280
  runtimeInstalled,
package/src/main.js CHANGED
@@ -1060,6 +1060,9 @@ async function synthesizeSpeech(text) {
1060
1060
  if (engine === 'piper') {
1061
1061
  return await piper.synthesize(String(text).slice(0, 4000), {
1062
1062
  voice: cfg.piperVoice || piper.DEFAULT_VOICE,
1063
+ speed: cfg.piperSpeed,
1064
+ sentenceSilence: cfg.piperSilence,
1065
+ noiseScale: cfg.piperNoise,
1063
1066
  });
1064
1067
  }
1065
1068
  if (!cfg.baseUrl || !cfg.key) return null;
@@ -7982,6 +7985,36 @@ function termSend(ev) {
7982
7985
  if (win && !win.isDestroyed()) win.webContents.send('agent:event', ev);
7983
7986
  }
7984
7987
 
7988
+ /* TERMİNAL ÇIKTI COALESCE: alt süreçten gelen data chunk'ları 80ms'de bir TEK
7989
+ 'term-batch' IPC'sinde birleştirilir. Aksi halde saniyede yüzlerce IPC +
7990
+ renderer'da satır satır DOM/layout = "Yanıt vermiyor" donması. */
7991
+ const TERM_BATCH_MS = 80;
7992
+ const TERM_BATCH_BYTES = 48 * 1024;
7993
+ const termBatches = new Map(); // id -> { timer, bytes, events: [{stream,text}] }
7994
+
7995
+ function termQueue(id, stream, text) {
7996
+ const key = String(id || termShellId || '');
7997
+ let b = termBatches.get(key);
7998
+ if (!b) {
7999
+ b = { timer: null, bytes: 0, events: [] };
8000
+ termBatches.set(key, b);
8001
+ }
8002
+ b.events.push({ stream, chunk: String(text) });
8003
+ b.bytes += String(text).length;
8004
+ if (b.bytes >= TERM_BATCH_BYTES) return termFlush(key); // tampon taştı — hemen boşalt
8005
+ if (!b.timer) b.timer = setTimeout(() => termFlush(key), TERM_BATCH_MS);
8006
+ }
8007
+
8008
+ function termFlush(id) {
8009
+ const key = String(id || '');
8010
+ const b = termBatches.get(key);
8011
+ if (!b) return;
8012
+ if (b.timer) { clearTimeout(b.timer); b.timer = null; }
8013
+ termBatches.delete(key);
8014
+ if (!b.events.length) return;
8015
+ termSend({ type: 'term-batch', id: key, events: b.events });
8016
+ }
8017
+
7985
8018
  /* Kalıcı kabuğu başlat: cmd /q /k — girdi pipe'ından satır satır okur,
7986
8019
  prompt yazmaz. chcp 65001 → Türkçe yollar (Masaüstü vb.) doğru çözülür. */
7987
8020
  function termShellSpawn() {
@@ -8006,14 +8039,15 @@ function termShellSpawn() {
8006
8039
  if (termForwarded > TERM_FWD_CAP) {
8007
8040
  if (!termCapNotified) {
8008
8041
  termCapNotified = true;
8042
+ termFlush(termShellId);
8009
8043
  termSend({ type: 'term-out', id: termShellId, stream: 'out', chunk: '\n[beast] çıktı çok büyük — iletim durduruldu (komut sürüyor)\n' });
8010
8044
  }
8011
8045
  return;
8012
8046
  }
8013
- termSend({ type: 'term-out', id: termShellId, stream: 'out', chunk: String(d) });
8047
+ termQueue(termShellId, 'out', String(d));
8014
8048
  });
8015
8049
  child.stderr.on('data', (d) => {
8016
- termSend({ type: 'term-out', id: termShellId, stream: 'err', chunk: String(d) });
8050
+ termQueue(termShellId, 'err', String(d));
8017
8051
  });
8018
8052
  child.on('exit', () => {
8019
8053
  if (termChild === child) termChild = null;
@@ -8038,6 +8072,7 @@ ipcMain.handle('terminal:run', (_e, payload) => {
8038
8072
  if (!termChild || !termChild.stdin.writable) termShellSpawn();
8039
8073
  if (!termChild) return { ok: false, error: 'kabuk başlatılamadı' };
8040
8074
  const id = 't' + Date.now().toString(36) + ++termShellSeq;
8075
+ termFlush(termShellId); // önceki komuttan kalan çıktı sırayı bozmasın
8041
8076
  termShellId = id;
8042
8077
  termForwarded = 0;
8043
8078
  termCapNotified = false;
@@ -8070,12 +8105,14 @@ ipcMain.handle('terminal:run', (_e, payload) => {
8070
8105
  }
8071
8106
  child.stdout.setEncoding('utf8');
8072
8107
  child.stderr.setEncoding('utf8');
8073
- child.stdout.on('data', (d) => termSend({ type: 'term-out', id, stream: 'out', chunk: String(d) }));
8074
- child.stderr.on('data', (d) => termSend({ type: 'term-out', id, stream: 'err', chunk: String(d) }));
8108
+ child.stdout.on('data', (d) => termQueue(id, 'out', String(d)));
8109
+ child.stderr.on('data', (d) => termQueue(id, 'err', String(d)));
8075
8110
  child.on('error', (err) => {
8111
+ termFlush(id);
8076
8112
  termSend({ type: 'term-end', id, code: -1, error: String((err && err.message) || err) });
8077
8113
  });
8078
8114
  child.on('close', (code) => {
8115
+ termFlush(id);
8079
8116
  termSend({ type: 'term-end', id, code: code == null ? -1 : code });
8080
8117
  });
8081
8118
  return { ok: true, id };
@@ -8089,6 +8126,7 @@ ipcMain.handle('terminal:stop', () => {
8089
8126
  try { termChild.kill(); } catch {}
8090
8127
  termChild = null;
8091
8128
  }
8129
+ termFlush(termShellId);
8092
8130
  termSend({ type: 'term-end', id: termShellId, code: 130, error: 'komut durduruldu — kalıcı CMD yeniden hazır' });
8093
8131
  termShellId = null;
8094
8132
  return { ok: true };
@@ -12879,6 +12917,13 @@ ipcMain.handle('wa:queue:get', () => mqueue.stats());
12879
12917
 
12880
12918
  ipcMain.handle('wa:sessions', () => [...waChats.values()]);
12881
12919
 
12920
+ /* TTS sayısal ayar kelepçesi: geçersiz/boş değerde varsayılana döner */
12921
+ function clampTtsNum(v, lo, hi, def) {
12922
+ const n = Number(v);
12923
+ if (!Number.isFinite(n)) return def;
12924
+ return Math.max(lo, Math.min(hi, n));
12925
+ }
12926
+
12882
12927
  ipcMain.handle('wa:tts:get', () => settings.waTts || {});
12883
12928
  ipcMain.handle('wa:tts:set', (_e, cfg) => {
12884
12929
  const eng = String((cfg && cfg.engine) || 'edge');
@@ -12889,6 +12934,10 @@ ipcMain.handle('wa:tts:set', (_e, cfg) => {
12889
12934
  engine: ['edge', 'piper', 'openai'].includes(eng) ? eng : 'edge',
12890
12935
  edgeVoice: String((cfg && cfg.edgeVoice) || 'tr-TR-AhmetNeural').trim(),
12891
12936
  piperVoice: piper.VOICES[pv] ? pv : piper.DEFAULT_VOICE,
12937
+ /* Piper ince ayar: hız (0.6-1.6), cümle sonu duraklama (0-1 sn), ifade (0-1.2) */
12938
+ piperSpeed: clampTtsNum(cfg && cfg.piperSpeed, 0.6, 1.6, 1),
12939
+ piperSilence: clampTtsNum(cfg && cfg.piperSilence, 0, 1, 0.2),
12940
+ piperNoise: clampTtsNum(cfg && cfg.piperNoise, 0, 1.2, 0.667),
12892
12941
  chatAutoSpeak: !!(cfg && cfg.chatAutoSpeak),
12893
12942
  baseUrl: String((cfg && cfg.baseUrl) || '').trim(),
12894
12943
  key: String((cfg && cfg.key) || '').trim(),
@@ -269,6 +269,12 @@
269
269
  tts_test: 'Test sesi çal',
270
270
  stt_download_now: 'Şimdi indir / hazırla',
271
271
  stt_engine: 'STT motoru',
272
+ stt_seg_auto: 'Otomatik',
273
+ stt_seg_whisper: 'Whisper (yerel)',
274
+ tts_stt_title: 'Ses → Yazı',
275
+ tts_stt_sub: 'Mikrofon kaydını yazıya çeviren motor',
276
+ tts_tts_title: 'Yazı → Ses',
277
+ tts_tts_sub: 'Sesli yanıt motoru ve ses seçimi',
272
278
  stt_engine_auto: 'Otomatik — anahtar varsa bulut, yoksa yerel Whisper',
273
279
  stt_engine_local: 'Whisper — yerel/offline (large-v3-turbo)',
274
280
  stt_engine_groq: 'Groq Whisper — bulut (hızlı)',
@@ -278,6 +284,9 @@
278
284
  tts_edge_voice: 'Edge sesi',
279
285
  tts_piper_voice: 'Piper sesi',
280
286
  tts_piper_download: 'Piper\'ı indir / hazırla',
287
+ tts_piper_speed: 'Konuşma hızı',
288
+ tts_piper_silence: 'Cümle sonu duraklama',
289
+ tts_piper_noise: 'İfade (doğallık)',
281
290
  tts_chat_auto: 'Chat\'te ajanın yazdıklarını otomatik seslendir',
282
291
  tts_on: 'TTS açık — cevaplar sesli de gider',
283
292
  tts_off: 'TTS kapalı',
@@ -1000,6 +1009,12 @@
1000
1009
  tts_test: 'Play test sound',
1001
1010
  stt_download_now: 'Download / prepare now',
1002
1011
  stt_engine: 'STT engine',
1012
+ stt_seg_auto: 'Auto',
1013
+ stt_seg_whisper: 'Whisper (local)',
1014
+ tts_stt_title: 'Speech → Text',
1015
+ tts_stt_sub: 'Engine that transcribes your microphone',
1016
+ tts_tts_title: 'Text → Speech',
1017
+ tts_tts_sub: 'Voice reply engine and voice',
1003
1018
  stt_engine_auto: 'Auto — cloud if a key exists, otherwise local Whisper',
1004
1019
  stt_engine_local: 'Whisper — local/offline (large-v3-turbo)',
1005
1020
  stt_engine_groq: 'Groq Whisper — cloud (fast)',
@@ -1009,6 +1024,9 @@
1009
1024
  tts_edge_voice: 'Edge voice',
1010
1025
  tts_piper_voice: 'Piper voice',
1011
1026
  tts_piper_download: 'Download / prepare Piper',
1027
+ tts_piper_speed: 'Speaking speed',
1028
+ tts_piper_silence: 'Sentence pause',
1029
+ tts_piper_noise: 'Expressiveness',
1012
1030
  tts_chat_auto: 'Auto-speak agent replies in chat',
1013
1031
  tts_on: 'TTS on — replies also go as voice',
1014
1032
  tts_off: 'TTS off',
@@ -2554,178 +2554,251 @@ function updateInstallPct(ev) {
2554
2554
  } catch {}
2555
2555
  }
2556
2556
 
2557
- async function renderTtsPane() { const pane = $('#tab-tts');
2557
+ async function renderTtsPane() {
2558
+ const pane = $('#tab-tts');
2558
2559
  if (!pane) return;
2559
2560
  /* STT DURUMU: model indirildi mi / iniyor mu / hangi motor */
2560
2561
  let stt = null;
2561
2562
  try { stt = await beast.sttStatus(); } catch {}
2562
- const stateText = {
2563
- ready: 'HAZIR — model yüklü',
2564
- loading: 'İNİYOR / yükleniyor…',
2565
- downloaded: 'indirildi — ilk kullanımda yüklenir',
2566
- partial: 'kısmen indi — devam edecek',
2567
- missing: 'henüz indirilmedi',
2568
- cloud: 'bulut motoru — indirme gerekmez',
2569
- };
2570
- const sttLine = stt
2571
- ? stt.engineLabel + ' · ' + (stateText[stt.state] || stt.state) + (stt.mb ? ' · ' + stt.mb + ' MB' : '')
2572
- : (window.beast ? 'bilinmiyor' : '');
2573
- const edgeOpts = [
2563
+ const EDGE_VOICES = [
2574
2564
  ['tr-TR-AhmetNeural', 'Ahmet — Türkçe (erkek)'],
2575
2565
  ['tr-TR-EmelNeural', 'Emel — Türkçe (kadın)'],
2576
2566
  ['en-US-GuyNeural', 'Guy — English (male)'],
2577
2567
  ['en-US-AriaNeural', 'Aria — English (female)'],
2578
2568
  ['de-DE-KatjaNeural', 'Katja — Deutsch'],
2579
2569
  ['ar-SA-HamedNeural', 'Hamed — العربية'],
2580
- ]
2581
- .map(([v, n]) => `<option value="${v}">${n}</option>`)
2582
- .join('');
2583
- const piperOpts = [
2570
+ ];
2571
+ const PIPER_VOICES = [
2584
2572
  ['tr_TR-fahrettin-medium', 'Fahrettin — Türkçe (erkek)'],
2585
2573
  ['tr_TR-fettah-medium', 'Fettah — Türkçe (erkek)'],
2586
2574
  ['en_US-lessac-medium', 'Lessac — English (male)'],
2587
2575
  ['en_US-amy-medium', 'Amy — English (female)'],
2588
- ]
2589
- .map(([v, n]) => `<option value="${v}">${n}</option>`)
2590
- .join('');
2576
+ ];
2577
+ const seg = (name, items) => '<div class="voice-seg" id="' + name + '">' +
2578
+ items.map(([v, n]) => `<label><input type="radio" name="${name}" value="${v}"/><span>${n}</span></label>`).join('') +
2579
+ '</div>';
2591
2580
  pane.innerHTML =
2592
2581
  '<h2>' + _t('tts_h2') + '</h2><div class="sub">' + _t('tts_sub') + '</div>' +
2593
- '<div class="sub" style="margin:10px 0 4px;font-weight:700;color:var(--accent)">STT (Ses → Yazı)</div>' +
2594
- `<div class="sub" id="sttStatusTxt" style="margin-bottom:8px">${sttLine}</div>` +
2595
- `<div class="form-grid" style="grid-template-columns:auto 1fr;align-items:center;margin-bottom:8px">
2596
- <label style="grid-column:1">${_t('stt_engine')}</label>
2597
- <select id="sttEngine" class="inp" style="grid-column:2">
2598
- <option value="">${_t('stt_engine_auto')}</option>
2599
- <option value="local">${_t('stt_engine_local')}</option>
2600
- <option value="groq">${_t('stt_engine_groq')}</option>
2601
- <option value="openai">${_t('stt_engine_openai')}</option>
2602
- </select>
2603
- </div>` +
2604
- `<button id="sttDlBtn" class="btn ghost" style="margin-bottom:14px">${_t('stt_download_now')}</button>` +
2605
- '<div class="sub" style="margin:10px 0 4px;font-weight:700;color:var(--accent)">TTS (Yazı → Ses)</div>' +
2606
- `<div class="form-grid" style="grid-template-columns:auto 1fr 1fr;align-items:center;margin-top:10px">
2607
- <label class="lock-row"><input type="checkbox" id="ttsOn" /><span>${_t('tts_active')}</span></label>
2608
- <label style="grid-column:1">${_t('tts_engine')}</label>
2609
- <select id="ttsEngine" class="inp" style="grid-column:2/4">
2610
- <option value="edge">Edge TTS — ücretsiz bulut (Ahmet/Emel…)</option>
2611
- <option value="piper">Piper — yerel & offline (Fahrettin/Fettah…)</option>
2612
- <option value="openai">OpenAI-uyumlu API (tts-1, ses: alloy…)</option>
2613
- </select>
2614
- <label style="grid-column:1">${_t('tts_edge_voice')}</label>
2615
- <select id="ttsEdgeVoice" class="inp" style="grid-column:2/4">${edgeOpts}</select>
2616
- <label style="grid-column:1">${_t('tts_piper_voice')}</label>
2617
- <select id="ttsPiperVoice" class="inp" style="grid-column:2/4">${piperOpts}</select>
2618
- <span id="piperStatusRow" style="grid-column:1/4;display:flex;gap:10px;align-items:center">
2619
- <span class="sub" id="piperStatusTxt" style="margin:0"></span>
2620
- <button id="piperDlBtn" class="btn ghost">${_t('tts_piper_download')}</button>
2621
- </span>
2622
- <label class="lock-row" style="grid-column:1/4"><input type="checkbox" id="ttsChatAuto" /><span>${_t('tts_chat_auto')}</span></label>
2623
- <span id="ttsOpenaiWrap" style="display:contents">
2624
- <input id="ttsUrl" class="inp" placeholder="https://api.openai.com/v1" autocomplete="off" />
2625
- <input id="ttsKey" class="inp" type="password" placeholder="API Key" autocomplete="off" />
2626
- <input id="ttsModel" class="inp" placeholder="tts-1" autocomplete="off" />
2627
- <input id="ttsVoice" class="inp" placeholder="ses: alloy" autocomplete="off" />
2628
- </span>
2629
- <button id="ttsSave" class="btn ghost" style="grid-column:1">${_t('tts_save')}</button>
2630
- <button id="ttsTest" class="btn ghost" style="grid-column:2;justify-self:start">${_t('tts_test')}</button>
2631
- </div>` +
2632
- '<div class="sub" style="margin-top:8px">' + _t('tts_note') + '</div>';
2582
+ `<div class="voice-card" style="margin-top:14px">
2583
+ <div class="voice-head">
2584
+ <div class="voice-ico">🎤</div>
2585
+ <div>
2586
+ <div class="voice-title">${_t('tts_stt_title')}</div>
2587
+ <div class="voice-sub">${_t('tts_stt_sub')}</div>
2588
+ </div>
2589
+ </div>
2590
+ ${seg('sttEngine', [['', _t('stt_seg_auto')], ['local', _t('stt_seg_whisper')], ['groq', 'Groq'], ['openai', 'OpenAI']])}
2591
+ <div class="voice-status">
2592
+ <span id="sttDot" class="voice-dot"></span><span id="sttStatusTxt"></span>
2593
+ <button id="sttDlBtn" class="btn ghost" style="margin-left:auto">${_t('stt_download_now')}</button>
2594
+ </div>
2595
+ </div>
2596
+ <div class="voice-card">
2597
+ <div class="voice-head">
2598
+ <div class="voice-ico">🔊</div>
2599
+ <div>
2600
+ <div class="voice-title">${_t('tts_tts_title')}</div>
2601
+ <div class="voice-sub">${_t('tts_tts_sub')}</div>
2602
+ </div>
2603
+ <label class="lock-row" style="margin-left:auto"><input type="checkbox" id="ttsOn" /><span>${_t('tts_active')}</span></label>
2604
+ </div>
2605
+ ${seg('ttsEngine', [['edge', 'Edge'], ['piper', 'Piper'], ['openai', 'OpenAI']])}
2606
+ <div class="voice-row">
2607
+ <select id="ttsVoiceSel" class="inp"></select>
2608
+ </div>
2609
+ <label class="lock-row" style="margin-top:10px"><input type="checkbox" id="ttsChatAuto" /><span>${_t('tts_chat_auto')}</span></label>
2610
+ <div class="voice-status" id="piperStatusRow">
2611
+ <span id="piperDot" class="voice-dot"></span><span id="piperStatusTxt"></span>
2612
+ <button id="piperDlBtn" class="btn ghost" style="margin-left:auto">${_t('tts_piper_download')}</button>
2613
+ </div>
2614
+ <div class="voice-sliders" id="piperAdvWrap">
2615
+ <div class="voice-slider"><span>${_t('tts_piper_speed')}</span><input id="piperSpeed" type="range" min="0.6" max="1.6" step="0.05" value="1"/><b id="piperSpeedVal">1.00x</b></div>
2616
+ <div class="voice-slider"><span>${_t('tts_piper_silence')}</span><input id="piperSil" type="range" min="0" max="1" step="0.05" value="0.2"/><b id="piperSilVal">0.20s</b></div>
2617
+ <div class="voice-slider"><span>${_t('tts_piper_noise')}</span><input id="piperNoise" type="range" min="0" max="1.2" step="0.05" value="0.667"/><b id="piperNoiseVal">0.67</b></div>
2618
+ </div>
2619
+ <div id="ttsOpenaiWrap" style="display:none">
2620
+ <div class="voice-row" style="margin-top:10px">
2621
+ <input id="ttsUrl" class="inp" placeholder="https://api.openai.com/v1" autocomplete="off" />
2622
+ <input id="ttsKey" class="inp" type="password" placeholder="API Key" autocomplete="off" />
2623
+ </div>
2624
+ <div class="voice-row">
2625
+ <input id="ttsModel" class="inp" placeholder="tts-1" autocomplete="off" />
2626
+ <input id="ttsVoice" class="inp" placeholder="alloy" autocomplete="off" />
2627
+ </div>
2628
+ </div>
2629
+ <div class="voice-actions">
2630
+ <button id="ttsSave" class="btn">${_t('tts_save')}</button>
2631
+ <button id="ttsTest" class="btn ghost">${_t('tts_test')}</button>
2632
+ </div>
2633
+ <div class="voice-note">${_t('tts_note')}</div>
2634
+ </div>`;
2635
+
2636
+ /* ---------- yardımcılar ---------- */
2637
+ const ttsEng = () => (document.querySelector('input[name="ttsEngine"]:checked') || {}).value || 'edge';
2638
+ const setSeg = (name, val) => {
2639
+ document.querySelectorAll('input[name="' + name + '"]').forEach((r) => {
2640
+ r.checked = r.value === String(val);
2641
+ const lab = r.closest('label');
2642
+ if (lab) lab.classList.toggle('on', r.checked);
2643
+ });
2644
+ };
2645
+ const refreshSeg = (name) => {
2646
+ document.querySelectorAll('input[name="' + name + '"]').forEach((r) => {
2647
+ const lab = r.closest('label');
2648
+ if (lab) lab.classList.toggle('on', r.checked);
2649
+ });
2650
+ };
2651
+ const sttName = (st) => (st.provider === 'groq' ? 'Groq Whisper' : st.provider === 'openai' ? 'Whisper (API)' : 'Whisper (yerel)');
2652
+ const sttState = {
2653
+ ready: 'hazır',
2654
+ loading: 'iniyor…',
2655
+ downloaded: 'hazır (ilk kullanımda yüklenir)',
2656
+ partial: 'kısmen indi',
2657
+ missing: 'indirilmedi',
2658
+ cloud: 'bulut',
2659
+ };
2660
+
2661
+ /* ---------- ses listeleri (motor başına) ---------- */
2662
+ let edgeVoice = 'tr-TR-AhmetNeural';
2663
+ let piperVoice = 'tr_TR-fahrettin-medium';
2664
+ const fillVoices = () => {
2665
+ const eng = ttsEng();
2666
+ const sel = $('#ttsVoiceSel');
2667
+ if (!sel) return;
2668
+ const list = eng === 'piper' ? PIPER_VOICES : EDGE_VOICES;
2669
+ sel.innerHTML = list.map(([v, n]) => `<option value="${v}">${n}</option>`).join('');
2670
+ sel.value = eng === 'piper' ? piperVoice : edgeVoice;
2671
+ sel.style.display = eng === 'openai' ? 'none' : '';
2672
+ };
2673
+ const captureVoice = () => {
2674
+ const eng = ttsEng();
2675
+ const sel = $('#ttsVoiceSel');
2676
+ if (!sel) return;
2677
+ if (eng === 'piper') piperVoice = sel.value || piperVoice;
2678
+ else if (eng === 'edge') edgeVoice = sel.value || edgeVoice;
2679
+ };
2633
2680
 
2634
2681
  const syncEngineUi = () => {
2635
- const eng = $('#ttsEngine').value;
2636
- const isEdge = eng === 'edge';
2682
+ const eng = ttsEng();
2637
2683
  const isPiper = eng === 'piper';
2638
- $('#ttsEdgeVoice').disabled = !isEdge;
2639
- $('#ttsPiperVoice').disabled = !isPiper;
2640
- $('#piperDlBtn').disabled = !isPiper;
2641
- $('#piperStatusTxt').style.opacity = isPiper ? '1' : '0.45';
2642
- $('#ttsOpenaiWrap').style.opacity = eng === 'openai' ? '1' : '0.35';
2643
- $('#ttsOpenaiWrap').querySelectorAll('input').forEach((i) => (i.disabled = eng !== 'openai'));
2684
+ $('#piperStatusRow').style.display = isPiper ? '' : 'none';
2685
+ $('#piperAdvWrap').style.display = isPiper ? '' : 'none';
2686
+ $('#ttsOpenaiWrap').style.display = eng === 'openai' ? '' : 'none';
2687
+ };
2688
+
2689
+ /* Piper ince ayar etiketleri (hız/duraklama/ifade) */
2690
+ const syncPiperLabels = () => {
2691
+ const sv = $('#piperSpeedVal'), lv = $('#piperSilVal'), nv = $('#piperNoiseVal');
2692
+ if (sv) sv.textContent = Number($('#piperSpeed').value).toFixed(2) + 'x';
2693
+ if (lv) lv.textContent = Number($('#piperSil').value).toFixed(2) + 's';
2694
+ if (nv) nv.textContent = Number($('#piperNoise').value).toFixed(2);
2695
+ };
2696
+
2697
+ /* ---------- durum satırları ---------- */
2698
+ const refreshPiperStatus = async () => {
2699
+ try {
2700
+ const st = await beast.piperStatus(piperVoice);
2701
+ const txt = $('#piperStatusTxt'), dot = $('#piperDot'), btn = $('#piperDlBtn');
2702
+ if (!st) return null;
2703
+ if (txt) txt.textContent = st.installed ? 'Hazır — yerel/offline' : st.installing ? 'İndiriliyor…' : 'İndirilmedi (~80MB)';
2704
+ if (dot) dot.className = 'voice-dot' + (st.installed ? ' ok' : st.installing ? ' warn' : '');
2705
+ if (btn) btn.disabled = !!st.installed;
2706
+ return st;
2707
+ } catch { return null; }
2708
+ };
2709
+ const refreshSttStatus = async () => {
2710
+ try {
2711
+ stt = await beast.sttStatus();
2712
+ const txt = $('#sttStatusTxt'), dot = $('#sttDot');
2713
+ if (txt && stt) txt.textContent = sttName(stt) + ' · ' + (sttState[stt.state] || stt.state) + (stt.mb ? ' · ' + stt.mb + ' MB' : '');
2714
+ if (dot && stt) {
2715
+ dot.className = 'voice-dot' + (
2716
+ ['ready', 'cloud', 'downloaded'].includes(stt.state) ? ' ok' :
2717
+ ['loading', 'partial'].includes(stt.state) ? ' warn' : ''
2718
+ );
2719
+ }
2720
+ return stt;
2721
+ } catch { return null; }
2644
2722
  };
2645
2723
 
2724
+ /* ---------- ayarları yükle ---------- */
2646
2725
  try {
2647
2726
  const tts = await beast.waGetTts();
2648
2727
  $('#ttsOn').checked = !!tts.enabled;
2649
- $('#ttsEngine').value = ['edge', 'piper', 'openai'].includes(tts.engine) ? tts.engine : 'edge';
2650
- $('#ttsEdgeVoice').value = tts.edgeVoice || 'tr-TR-AhmetNeural';
2651
- $('#ttsPiperVoice').value = tts.piperVoice || 'tr_TR-fahrettin-medium';
2728
+ edgeVoice = tts.edgeVoice || edgeVoice;
2729
+ piperVoice = tts.piperVoice || piperVoice;
2730
+ $('#piperSpeed').value = Number.isFinite(Number(tts.piperSpeed)) ? tts.piperSpeed : 1;
2731
+ $('#piperSil').value = Number.isFinite(Number(tts.piperSilence)) ? tts.piperSilence : 0.2;
2732
+ $('#piperNoise').value = Number.isFinite(Number(tts.piperNoise)) ? tts.piperNoise : 0.667;
2652
2733
  $('#ttsChatAuto').checked = !!tts.chatAutoSpeak;
2653
2734
  $('#ttsUrl').value = tts.baseUrl || '';
2654
2735
  $('#ttsKey').value = tts.key || '';
2655
2736
  $('#ttsModel').value = tts.model || 'tts-1';
2656
2737
  $('#ttsVoice').value = tts.voice || 'alloy';
2738
+ setSeg('ttsEngine', ['edge', 'piper', 'openai'].includes(tts.engine) ? tts.engine : 'edge');
2657
2739
  } catch {}
2658
-
2659
- /* STT motoru seçimi: '' = otomatik (bulut anahtarı varsa bulut, yoksa yerel) */
2660
- const sttEngineSel = $('#sttEngine');
2661
- if (sttEngineSel) sttEngineSel.value = (stt && stt.pref) || '';
2662
-
2663
- /* Piper durum satırı: runtime/ses modeli hazır mı (indirilince güncellenir) */
2664
- const refreshPiperStatus = async () => {
2665
- const v = $('#ttsPiperVoice') ? $('#ttsPiperVoice').value : '';
2666
- try {
2667
- const st = await beast.piperStatus(v);
2668
- const el = $('#piperStatusTxt');
2669
- if (el && st) {
2670
- el.textContent = 'Piper: ' + (st.installed
2671
- ? 'hazır — yerel/offline'
2672
- : st.installing
2673
- ? 'indiriliyor…'
2674
- : st.runtime
2675
- ? 'ses modeli indirilecek (ilk kullanımda otomatik)'
2676
- : 'runtime + ses modeli indirilecek (ilk kullanımda otomatik)');
2677
- }
2678
- return st;
2679
- } catch { return null; }
2680
- };
2681
-
2740
+ setSeg('sttEngine', (stt && stt.pref) || '');
2741
+ syncPiperLabels();
2742
+ fillVoices();
2682
2743
  syncEngineUi();
2683
2744
  await refreshPiperStatus();
2684
- $('#ttsEngine').addEventListener('change', () => { syncEngineUi(); refreshPiperStatus(); });
2685
- $('#ttsPiperVoice').addEventListener('change', refreshPiperStatus);
2745
+ await refreshSttStatus();
2746
+
2747
+ /* ---------- olaylar ---------- */
2748
+ ['piperSpeed', 'piperSil', 'piperNoise'].forEach((id) => {
2749
+ const el = $('#' + id);
2750
+ if (el) el.addEventListener('input', syncPiperLabels);
2751
+ });
2752
+ document.querySelectorAll('input[name="ttsEngine"]').forEach((r) => {
2753
+ r.addEventListener('change', () => {
2754
+ captureVoice();
2755
+ refreshSeg('ttsEngine');
2756
+ fillVoices();
2757
+ syncEngineUi();
2758
+ refreshPiperStatus();
2759
+ });
2760
+ });
2761
+ document.querySelectorAll('input[name="sttEngine"]').forEach((r) => {
2762
+ r.addEventListener('change', async () => {
2763
+ refreshSeg('sttEngine');
2764
+ const res = await beast.sttProviderSet(r.value).catch(() => null);
2765
+ if (res && res.ok) {
2766
+ await refreshSttStatus();
2767
+ toast('STT motoru: ' + sttName(res));
2768
+ } else {
2769
+ toast('STT motoru değiştirilemedi');
2770
+ }
2771
+ });
2772
+ });
2686
2773
  $('#piperDlBtn').addEventListener('click', async () => {
2687
- const v = $('#ttsPiperVoice').value;
2774
+ captureVoice();
2688
2775
  toast('Piper indiriliyor… (ilk seferde ~80MB)');
2689
2776
  const poll = setInterval(refreshPiperStatus, 4000);
2690
- const r = await beast.piperInstall(v).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
2777
+ const r = await beast.piperInstall(piperVoice).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
2691
2778
  clearInterval(poll);
2692
2779
  await refreshPiperStatus();
2693
2780
  toast(r && r.ok ? 'Piper hazır ✓ — artık çevrimdışı seslendirme yapılır' : 'Piper indirilemedi: ' + ((r && r.error) || '?'));
2694
2781
  });
2695
-
2696
- /* STT durum satırı: yenile + şimdi indir (yüklenirken 3 sn'de bir güncellenir) */
2697
- const refreshSttStatus = async () => {
2698
- try {
2699
- stt = await beast.sttStatus();
2700
- const el = $('#sttStatusTxt');
2701
- if (el && stt) el.textContent = stt.engineLabel + ' · ' + (stateText[stt.state] || stt.state) + (stt.mb ? ' · ' + stt.mb + ' MB' : '');
2702
- return stt;
2703
- } catch { return null; }
2704
- };
2705
2782
  $('#sttDlBtn').addEventListener('click', async () => {
2783
+ const s0 = await refreshSttStatus();
2784
+ if (s0 && s0.state === 'cloud') { toast('Bulut motoru seçili — indirme gerekmez'); return; }
2706
2785
  await beast.sttPrefetchNow().catch(() => {});
2707
2786
  toast('STT modeli indiriliyor/yükleniyor…');
2708
2787
  const poll = setInterval(async () => {
2709
- const s = await refreshSttStatus();
2710
- if (s && (s.state === 'ready' || s.state === 'cloud')) { clearInterval(poll); toast('STT hazır'); }
2788
+ const s2 = await refreshSttStatus();
2789
+ if (s2 && (s2.state === 'ready' || s2.state === 'cloud')) { clearInterval(poll); toast('STT hazır ✓'); }
2711
2790
  }, 3000);
2712
2791
  });
2713
- /* STT motoru: Whisper yerel / Groq / OpenAI-uyumlu — anında kaydedilir */
2714
- if (sttEngineSel) sttEngineSel.addEventListener('change', async () => {
2715
- const r = await beast.sttProviderSet(sttEngineSel.value).catch(() => null);
2716
- if (r && r.ok) {
2717
- await refreshSttStatus();
2718
- toast('STT motoru: ' + (r.engineLabel || 'otomatik'));
2719
- } else {
2720
- toast('STT motoru değiştirilemedi');
2721
- }
2722
- });
2723
2792
  $('#ttsSave').addEventListener('click', async () => {
2793
+ captureVoice();
2724
2794
  await beast.waSetTts({
2725
2795
  enabled: $('#ttsOn').checked,
2726
- engine: $('#ttsEngine').value,
2727
- edgeVoice: $('#ttsEdgeVoice').value,
2728
- piperVoice: $('#ttsPiperVoice').value,
2796
+ engine: ttsEng(),
2797
+ edgeVoice,
2798
+ piperVoice,
2799
+ piperSpeed: Number($('#piperSpeed').value),
2800
+ piperSilence: Number($('#piperSil').value),
2801
+ piperNoise: Number($('#piperNoise').value),
2729
2802
  chatAutoSpeak: $('#ttsChatAuto').checked,
2730
2803
  baseUrl: $('#ttsUrl').value.trim(),
2731
2804
  key: $('#ttsKey').value.trim(),
@@ -2740,7 +2813,7 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2740
2813
  });
2741
2814
  $('#ttsTest').addEventListener('click', async () => {
2742
2815
  /* motor + IPC + çalma zincirini uçtan uca dener — sorun neredeyse görünür */
2743
- const eng = $('#ttsEngine').value;
2816
+ const eng = ttsEng();
2744
2817
  const line = eng === 'piper'
2745
2818
  ? 'Merhaba kanka, Piper yerel TTS testi. Ben Beast.'
2746
2819
  : eng === 'openai'
@@ -6114,29 +6187,13 @@ function onEvent(ev) {
6114
6187
  if (!busy) break;
6115
6188
  setStatus(ev.status === 'idle' ? (busy ? 'düşünüyor…' : '') : ev.status === 'thinking' ? 'düşünüyor…' : ev.status + '…');
6116
6189
  break;
6117
- case 'term-out': {
6118
- /* KALICI CMD: her komutun sonunda marker satırı gelir —
6119
- __BEAST_EOF__<yol>__BEAST_EOF__ → yazdırılmaz, cwd güncellenir, kilit açılır */
6120
- const chunk = String(ev.chunk || '');
6121
- if (ev.stream === 'err') { termLine('t-err', chunk, false); break; }
6122
- termPendBuf += chunk;
6123
- const parts = termPendBuf.split(/\r?\n/);
6124
- termPendBuf = parts.pop(); /* son yarım satır bekler */
6125
- for (const line of parts) {
6126
- const m = /__BEAST_EOF__(.*)__BEAST_EOF__/.exec(line.trim());
6127
- if (m) {
6128
- const cwd2 = m[1].trim();
6129
- if (cwd2) {
6130
- els.termCwd.textContent = cwd2;
6131
- els.termCwd.title = cwd2;
6132
- }
6133
- termCmdDone({ code: 0, silent: true });
6134
- continue;
6135
- }
6136
- if (line.trim()) termLine('t-out', line, false);
6137
- }
6190
+ case 'term-out':
6191
+ termHandleOut(ev);
6192
+ break;
6193
+ case 'term-batch':
6194
+ /* main 80ms'de bir birleştirilmiş çıktı paketi gönderir (IPC/satır yükü azalır) */
6195
+ for (const sub of (ev.events || [])) termHandleOut({ ...sub, id: ev.id });
6138
6196
  break;
6139
- }
6140
6197
  case 'term-end':
6141
6198
  termCmdDone(ev);
6142
6199
  break;
@@ -6243,6 +6300,7 @@ function termSetHeight(h) {
6243
6300
  function termSetOpen(v) {
6244
6301
  termOpen = !!v;
6245
6302
  els.termPanel.hidden = !v;
6303
+ if (v) termFlushLines(); // panel kapalıyken biriken satırlar şimdi basılır
6246
6304
  els.termResize.hidden = !v || ideModeOn(); /* IDE'de terminal bölme içi — boyutlandırma yok */
6247
6305
  document.body.classList.toggle('term-open', v);
6248
6306
  termSetShell(termShell);
@@ -6271,16 +6329,38 @@ function termScroll(force) {
6271
6329
  if (force || near) b.scrollTop = b.scrollHeight;
6272
6330
  }
6273
6331
 
6274
- function termLine(cls, text, time = true) {
6275
- const el = document.createElement('div');
6276
- el.className = 't-line ' + (cls || '');
6277
- const ts = time ? '<span class="t-time">' + new Date().toTimeString().slice(0, 8) + '</span> ' : '';
6278
- el.innerHTML = ts + escapeHtml(String(text ?? ''));
6279
- els.termOut.appendChild(el);
6332
+ /* satır KUYRUĞU: komut çıktısı hızlı akarken satır satır DOM + scroll/layout
6333
+ renderer'ı kilitliyordu ("Yanıt vermiyor"). Satırlar tampona girer, 100ms'de
6334
+ bir TEK fragment ile DOM'a basılır; panel kapalıysa DOM'a hiç dokunulmaz. */
6335
+ let termLineBuf = [];
6336
+ let termLineTimer = null;
6337
+
6338
+ function termFlushLines() {
6339
+ if (termLineTimer) { clearTimeout(termLineTimer); termLineTimer = null; }
6340
+ if (!termLineBuf.length || !els.termOut) return;
6341
+ const items = termLineBuf.splice(0);
6342
+ const frag = document.createDocumentFragment();
6343
+ for (const it of items) {
6344
+ const el = document.createElement('div');
6345
+ el.className = 't-line ' + (it.cls || '');
6346
+ const ts = it.time ? '<span class="t-time">' + new Date().toTimeString().slice(0, 8) + '</span> ' : '';
6347
+ el.innerHTML = ts + escapeHtml(String(it.text ?? ''));
6348
+ frag.appendChild(el);
6349
+ }
6350
+ els.termOut.appendChild(frag);
6280
6351
  while (els.termOut.childElementCount > TERM_MAX_LINES) els.termOut.removeChild(els.termOut.firstChild);
6281
6352
  termScroll();
6282
6353
  }
6283
6354
 
6355
+ function termLine(cls, text, time = true) {
6356
+ termLineBuf.push({ cls, text, time });
6357
+ const cap = TERM_MAX_LINES * 2;
6358
+ if (termLineBuf.length > cap) termLineBuf.splice(0, termLineBuf.length - cap);
6359
+ if (!termOpen) return; /* panel kapalı — açılınca termFlushLines ile basılır */
6360
+ if (termLineBuf.length >= 300) termFlushLines();
6361
+ else if (!termLineTimer) termLineTimer = setTimeout(termFlushLines, 100);
6362
+ }
6363
+
6284
6364
  function termBanner(cwd) {
6285
6365
  if (termBannerDone) return;
6286
6366
  termBannerDone = true;
@@ -6319,6 +6399,29 @@ function termAgentEvent(ev) {
6319
6399
  }
6320
6400
  }
6321
6401
 
6402
+ /* KALICI CMD çıktısı: her komutun sonunda marker satırı gelir —
6403
+ __BEAST_EOF__<yol>__BEAST_EOF__ → yazdırılmaz, cwd güncellenir, kilit açılır */
6404
+ function termHandleOut(ev) {
6405
+ const chunk = String(ev.chunk || '');
6406
+ if (ev.stream === 'err') { termLine('t-err', chunk, false); return; }
6407
+ termPendBuf += chunk;
6408
+ const parts = termPendBuf.split(/\r?\n/);
6409
+ termPendBuf = parts.pop(); /* son yarım satır bekler */
6410
+ for (const line of parts) {
6411
+ const m = /__BEAST_EOF__(.*)__BEAST_EOF__/.exec(line.trim());
6412
+ if (m) {
6413
+ const cwd2 = m[1].trim();
6414
+ if (cwd2) {
6415
+ els.termCwd.textContent = cwd2;
6416
+ els.termCwd.title = cwd2;
6417
+ }
6418
+ termCmdDone({ code: 0, silent: true });
6419
+ continue;
6420
+ }
6421
+ if (line.trim()) termLine('t-out', line, false);
6422
+ }
6423
+ }
6424
+
6322
6425
  function termCmdDone(ev) {
6323
6426
  termRunning = false;
6324
6427
  els.termStop.hidden = true;
@@ -7639,7 +7742,7 @@ async function init() {
7639
7742
  try { termSetHeight(parseInt(localStorage.getItem('beast.termH')) || 180); } catch {}
7640
7743
  if (els.termCBtn) els.termCBtn.addEventListener('click', () => termToggle('cmd'));
7641
7744
  if (els.termClose) els.termClose.addEventListener('click', () => termSetOpen(false));
7642
- if (els.termClear) els.termClear.addEventListener('click', () => { els.termOut.innerHTML = ''; });
7745
+ if (els.termClear) els.termClear.addEventListener('click', () => { els.termOut.innerHTML = ''; termLineBuf = []; });
7643
7746
  if (els.termStop) els.termStop.addEventListener('click', () => beast.terminalStop().catch(() => {}));
7644
7747
  if (els.termInput) els.termInput.addEventListener('keydown', (e) => {
7645
7748
  if (e.key === 'Enter') {
@@ -1741,6 +1741,63 @@ body.browser-open #settingsDialog { width: min(70vw, calc(100vw - var(--bw, 480p
1741
1741
  .inp:focus { border-color: var(--accent); }
1742
1742
  .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
1743
1743
 
1744
+ /* ---------------- 🎙 Sesli Yanıt (STT / TTS) ---------------- */
1745
+ .voice-card {
1746
+ border: 1px solid var(--border);
1747
+ border-radius: 12px;
1748
+ background: var(--panel);
1749
+ padding: 16px 18px;
1750
+ max-width: 640px;
1751
+ }
1752
+ .voice-card + .voice-card { margin-top: 14px; }
1753
+ .voice-head { display: flex; align-items: center; gap: 11px; }
1754
+ .voice-ico {
1755
+ width: 34px; height: 34px; flex: none;
1756
+ border-radius: 9px;
1757
+ display: flex; align-items: center; justify-content: center;
1758
+ font-size: 17px;
1759
+ background: var(--accent-dim);
1760
+ color: var(--accent);
1761
+ }
1762
+ .voice-title { font-weight: 700; font-size: 13.5px; }
1763
+ .voice-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
1764
+ .voice-seg { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 13px; }
1765
+ .voice-seg label {
1766
+ position: relative;
1767
+ border: 1px solid var(--border);
1768
+ border-radius: 9px;
1769
+ padding: 7px 13px;
1770
+ font-size: 12.5px;
1771
+ cursor: pointer;
1772
+ color: var(--muted);
1773
+ background: var(--panel2);
1774
+ user-select: none;
1775
+ transition: border-color .12s, color .12s, background .12s;
1776
+ }
1777
+ .voice-seg label:hover { border-color: var(--accent); color: var(--text); }
1778
+ .voice-seg input { position: absolute; opacity: 0; pointer-events: none; }
1779
+ .voice-seg label.on { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); font-weight: 700; }
1780
+ .voice-row { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
1781
+ .voice-row .inp { flex: 1; min-width: 220px; margin: 0; }
1782
+ .voice-status { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12.5px; color: var(--muted); flex-wrap: wrap; }
1783
+ .voice-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
1784
+ .voice-dot.ok { background: var(--ok); }
1785
+ .voice-dot.warn { background: #d9a441; }
1786
+ .voice-sliders { margin-top: 12px; border-top: 1px dashed var(--border); padding-top: 10px; }
1787
+ .voice-slider {
1788
+ display: grid;
1789
+ grid-template-columns: 132px 1fr 48px;
1790
+ align-items: center;
1791
+ gap: 10px;
1792
+ margin-top: 7px;
1793
+ font-size: 12.5px;
1794
+ color: var(--muted);
1795
+ }
1796
+ .voice-slider input[type=range] { width: 100%; accent-color: var(--accent); }
1797
+ .voice-slider b { text-align: right; color: var(--text); font-variant-numeric: tabular-nums; }
1798
+ .voice-actions { display: flex; gap: 8px; margin-top: 15px; }
1799
+ .voice-note { font-size: 12px; color: var(--muted); margin-top: 11px; line-height: 1.5; }
1800
+
1744
1801
  /* ---------------- ☢ Fallout ---------------- */
1745
1802
  .fo-toggles {
1746
1803
  display: flex;