beast-agent 2.44.9 → 2.44.11

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.9",
4
+ "version": "2.44.11",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ /* Beast Piper TTS: tamamen YEREL/offline neural seslendirme (rhasspy/piper).
4
+ - Runtime: piper_windows_amd64.zip (~21MB) → %APPDATA%\beast\piper\bin
5
+ (piper.exe + espeak-ng-data + onnxruntime dll'leri birlikte gelir)
6
+ - Sesler: diffusionstudio/piper-voices aynası → %APPDATA%\beast\piper\voices
7
+ (tr_TR fahrettin/fettah medium = CC0; en_US lessac/amy medium)
8
+ - piper.exe stdin metin → stdout WAV (22.05kHz mono 16-bit). mp3/ogg
9
+ dönüşümü gerekmez: WhatsApp sesli not yolu ffmpeg ile ogg/opus'a çevirir,
10
+ masaüstü oynatıcı WAV'ı doğrudan çalar.
11
+ - İndirmeler kilitli: aynı anda iki kez başlamaz; ilerleme progressbus
12
+ üzerinden Kurulum/Ayarlar panesine akar. Hata olursa bir sonraki
13
+ çağrıda yeniden denenir. */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawn, execFile } = require('child_process');
18
+ const { Readable } = require('stream');
19
+ const { pipeline } = require('stream/promises');
20
+ const { beastRoot } = require('./memory');
21
+ const log = require('./logger');
22
+ const { emitInstallProgress } = require('./progressbus');
23
+
24
+ const PIPER_VERSION = '2023.11.14-2';
25
+ const RUNTIME_URL = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/piper_windows_amd64.zip`;
26
+ const VOICE_BASE = 'https://huggingface.co/diffusionstudio/piper-voices/resolve/main';
27
+ const DEFAULT_VOICE = 'tr_TR-fahrettin-medium';
28
+
29
+ /* UI'da listelenen sesler — indirme url'si VOICE_BASE + path + .onnx(.json) */
30
+ const VOICES = {
31
+ 'tr_TR-fahrettin-medium': { name: 'Fahrettin — Türkçe (erkek)', path: 'tr/tr_TR/fahrettin/medium/tr_TR-fahrettin-medium' },
32
+ 'tr_TR-fettah-medium': { name: 'Fettah — Türkçe (erkek)', path: 'tr/tr_TR/fettah/medium/tr_TR-fettah-medium' },
33
+ 'en_US-lessac-medium': { name: 'Lessac — English (male)', path: 'en/en_US/lessac/medium/en_US-lessac-medium' },
34
+ 'en_US-amy-medium': { name: 'Amy — English (female)', path: 'en/en_US/amy/medium/en_US-amy-medium' },
35
+ };
36
+
37
+ function rootDir() {
38
+ return path.join(beastRoot(), 'piper');
39
+ }
40
+ function binDir() {
41
+ return path.join(rootDir(), 'bin');
42
+ }
43
+ function voicesDir() {
44
+ return path.join(rootDir(), 'voices');
45
+ }
46
+ function exePath() {
47
+ return path.join(binDir(), 'piper.exe');
48
+ }
49
+ function voicePath(id) {
50
+ return path.join(voicesDir(), String(id) + '.onnx');
51
+ }
52
+ function voiceJsonPath(id) {
53
+ return voicePath(id) + '.json';
54
+ }
55
+
56
+ function resolveVoice(id) {
57
+ const v = String(id || '').trim();
58
+ return VOICES[v] ? v : DEFAULT_VOICE;
59
+ }
60
+
61
+ function runtimeInstalled() {
62
+ try {
63
+ return fs.existsSync(exePath());
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ function voiceInstalled(id) {
69
+ try {
70
+ return !!VOICES[id] && fs.existsSync(voicePath(id)) && fs.existsSync(voiceJsonPath(id));
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /* ---------- indirme (yüzde akışlı, .part → atomik rename) ---------- */
77
+
78
+ async function download(url, dest, tag) {
79
+ const res = await fetch(url, { redirect: 'follow' });
80
+ if (!res.ok || !res.body) throw new Error('indirilemedi (http ' + res.status + ')');
81
+ const total = Number(res.headers.get('content-length')) || 0;
82
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
83
+ const tmp = dest + '.part';
84
+ let loaded = 0;
85
+ let last = 0;
86
+ const rs = Readable.fromWeb(res.body);
87
+ rs.on('data', (c) => {
88
+ loaded += c.length;
89
+ const now = Date.now();
90
+ if (now - last > 500) {
91
+ last = now;
92
+ emitInstallProgress(tag, { pct: total ? Math.round((loaded / total) * 100) : 0, loaded, total });
93
+ }
94
+ });
95
+ await pipeline(rs, fs.createWriteStream(tmp));
96
+ fs.renameSync(tmp, dest);
97
+ emitInstallProgress(tag, { pct: 100, loaded, total: total || loaded });
98
+ }
99
+
100
+ function unzip(zip, dest) {
101
+ return new Promise((resolve, reject) => {
102
+ const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
103
+ execFile(
104
+ 'powershell.exe',
105
+ ['-NoProfile', '-NonInteractive', '-Command',
106
+ `Expand-Archive -LiteralPath ${q(zip)} -DestinationPath ${q(dest)} -Force`],
107
+ { timeout: 5 * 60 * 1000, windowsHide: true },
108
+ (err) => (err ? reject(err) : resolve())
109
+ );
110
+ });
111
+ }
112
+
113
+ /* ---------- kurulum (kilitli) ---------- */
114
+
115
+ let runtimePromise = null;
116
+ const voicePromises = new Map();
117
+
118
+ function ensureRuntime() {
119
+ if (runtimeInstalled()) return Promise.resolve(true);
120
+ if (runtimePromise) return runtimePromise;
121
+ runtimePromise = (async () => {
122
+ const zip = path.join(rootDir(), 'piper-' + PIPER_VERSION + '.zip');
123
+ log.info('piper', 'runtime indiriliyor: ' + RUNTIME_URL);
124
+ await download(RUNTIME_URL, zip, 'piper-runtime');
125
+ const tmp = path.join(rootDir(), '.extract');
126
+ fs.rmSync(tmp, { recursive: true, force: true });
127
+ fs.mkdirSync(tmp, { recursive: true });
128
+ await unzip(zip, tmp);
129
+ const inner = path.join(tmp, 'piper');
130
+ const src = fs.existsSync(path.join(inner, 'piper.exe')) ? inner : tmp;
131
+ fs.mkdirSync(binDir(), { recursive: true });
132
+ for (const e of fs.readdirSync(src)) {
133
+ const from = path.join(src, e);
134
+ const to = path.join(binDir(), e);
135
+ fs.rmSync(to, { recursive: true, force: true });
136
+ fs.renameSync(from, to);
137
+ }
138
+ fs.rmSync(tmp, { recursive: true, force: true });
139
+ try { fs.unlinkSync(zip); } catch {}
140
+ if (!runtimeInstalled()) throw new Error('piper.exe çıkarılamadı');
141
+ log.info('piper', 'runtime hazır: ' + exePath());
142
+ return true;
143
+ })().catch((e) => {
144
+ log.error('piper', 'runtime kurulumu başarısız: ' + String((e && e.message) || e));
145
+ throw e;
146
+ }).finally(() => {
147
+ runtimePromise = null; // kilit çözülür; kuruluysa yukarıdaki kısa yol devrede
148
+ });
149
+ return runtimePromise;
150
+ }
151
+
152
+ function ensureVoice(id) {
153
+ const vid = resolveVoice(id);
154
+ if (voiceInstalled(vid)) return Promise.resolve(true);
155
+ if (voicePromises.has(vid)) return voicePromises.get(vid);
156
+ const p = (async () => {
157
+ const base = VOICE_BASE + '/' + VOICES[vid].path;
158
+ log.info('piper', 'ses modeli indiriliyor: ' + vid);
159
+ await download(base + '.onnx', voicePath(vid), 'piper-voice');
160
+ await download(base + '.onnx.json', voiceJsonPath(vid), 'piper-voice');
161
+ if (!voiceInstalled(vid)) throw new Error('ses modeli indirilemedi: ' + vid);
162
+ log.info('piper', 'ses hazır: ' + vid);
163
+ return true;
164
+ })().catch((e) => {
165
+ log.error('piper', 'ses indirme başarısız (' + vid + '): ' + String((e && e.message) || e));
166
+ throw e;
167
+ }).finally(() => {
168
+ voicePromises.delete(vid); // kilit çözülür; kuruluysa yukarıdaki kısa yol devrede
169
+ });
170
+ voicePromises.set(vid, p);
171
+ return p;
172
+ }
173
+
174
+ /* UI/Kurulum için durum: { installed, runtime, voice, voiceId, installing } */
175
+ function status(voiceId) {
176
+ const vid = resolveVoice(voiceId);
177
+ return {
178
+ installed: runtimeInstalled() && voiceInstalled(vid),
179
+ runtime: runtimeInstalled(),
180
+ voice: voiceInstalled(vid),
181
+ voiceId: vid,
182
+ installing: !!runtimePromise || voicePromises.has(vid),
183
+ };
184
+ }
185
+
186
+ /* elle/otomatik kurulum: runtime + ses (hata fırlatmaz, {ok} döner) */
187
+ async function install(voiceId) {
188
+ const vid = resolveVoice(voiceId);
189
+ try {
190
+ await ensureRuntime();
191
+ await ensureVoice(vid);
192
+ return { ok: true, voice: vid };
193
+ } catch (e) {
194
+ return { ok: false, voice: vid, error: String((e && e.message) || e) };
195
+ }
196
+ }
197
+
198
+ /* ---------- sentez: metin → { audio: WAV buffer, mime } ---------- */
199
+
200
+ function synthesize(text, opts = {}) {
201
+ const vid = resolveVoice(opts.voice);
202
+ return (async () => {
203
+ await ensureRuntime();
204
+ await ensureVoice(vid);
205
+ return new Promise((resolve, reject) => {
206
+ const args = [
207
+ '--model', voicePath(vid),
208
+ '--config', voiceJsonPath(vid),
209
+ '--output_file', '-',
210
+ '-q',
211
+ ];
212
+ const p = spawn(exePath(), args, { windowsHide: true, cwd: binDir() });
213
+ const chunks = [];
214
+ let err = '';
215
+ const timer = setTimeout(() => {
216
+ try { p.kill(); } catch {}
217
+ reject(new Error('piper zaman aşımı'));
218
+ }, 120000);
219
+ p.stdout.on('data', (d) => chunks.push(d));
220
+ p.stderr.on('data', (d) => { err += d.toString(); });
221
+ p.on('error', (e) => { clearTimeout(timer); reject(e); });
222
+ p.on('close', (code) => {
223
+ clearTimeout(timer);
224
+ const buf = Buffer.concat(chunks);
225
+ if (code !== 0 || buf.length < 44) {
226
+ return reject(new Error('piper başarısız (kod ' + code + '): ' + err.slice(0, 140)));
227
+ }
228
+ resolve({ audio: buf, mime: 'audio/wav' });
229
+ });
230
+ p.stdin.write(String(text).slice(0, 4000));
231
+ p.stdin.end();
232
+ });
233
+ })();
234
+ }
235
+
236
+ module.exports = {
237
+ VOICES,
238
+ DEFAULT_VOICE,
239
+ synthesize,
240
+ install,
241
+ status,
242
+ ensureRuntime,
243
+ ensureVoice,
244
+ runtimeInstalled,
245
+ voiceInstalled,
246
+ };
package/src/main.js CHANGED
@@ -26,6 +26,7 @@ const watchers = require('./agent/watchers');
26
26
  const usageMod = require('./agent/usage');
27
27
  const bus = require('./agent/bus');
28
28
  const computeruse = require('./agent/computeruse');
29
+ const piper = require('./agent/piper');
29
30
  const fsguard = require('./agent/fsguard');
30
31
  const log = require('./agent/logger');
31
32
  const squeeze = require('./agent/squeeze');
@@ -1035,17 +1036,28 @@ async function transcribePcm(audio, langOverride) {
1035
1036
  }
1036
1037
  }
1037
1038
 
1039
+ /* TTS sentezi: dönen değer { audio: Buffer, mime } — motor seçilebilir.
1040
+ - edge: ücretsiz Microsoft neural (MP3)
1041
+ - piper: tamamen yerel/offline neural (WAV; ilk kullanımda indirilir)
1042
+ - openai: OpenAI-uyumlu /audio/speech API (MP3) */
1038
1043
  async function synthesizeSpeech(text) {
1039
1044
  const cfg = settings.waTts || {};
1040
1045
  if (!cfg.enabled) return null;
1041
1046
  try {
1047
+ const engine = cfg.engine || 'edge';
1042
1048
  /* EDGE TTS: ücretsiz yerel motor — baseUrl/key GEREKMEZ */
1043
- if ((cfg.engine || 'edge') === 'edge') {
1049
+ if (engine === 'edge') {
1044
1050
  const edge = require('./agent/edgetts');
1045
1051
  const audio = await edge.synthesize(String(text).slice(0, 4000), {
1046
1052
  voice: cfg.edgeVoice || 'tr-TR-AhmetNeural',
1047
1053
  });
1048
- return audio;
1054
+ return audio && { audio, mime: 'audio/mpeg' };
1055
+ }
1056
+ /* PIPER: yerel/offline — ilk kullanımda runtime + ses modeli indirilir */
1057
+ if (engine === 'piper') {
1058
+ return await piper.synthesize(String(text).slice(0, 4000), {
1059
+ voice: cfg.piperVoice || piper.DEFAULT_VOICE,
1060
+ });
1049
1061
  }
1050
1062
  if (!cfg.baseUrl || !cfg.key) return null;
1051
1063
  const url = String(cfg.baseUrl).replace(/\/+$/, '') + '/audio/speech';
@@ -1068,7 +1080,7 @@ async function synthesizeSpeech(text) {
1068
1080
  waLog(`tts http ${res.status}`);
1069
1081
  return null;
1070
1082
  }
1071
- return Buffer.from(await res.arrayBuffer());
1083
+ return { audio: Buffer.from(await res.arrayBuffer()), mime: 'audio/mpeg' };
1072
1084
  } catch (e) {
1073
1085
  waLog('tts hata: ' + String((e && e.message) || e));
1074
1086
  return null;
@@ -3362,6 +3374,8 @@ function reloadBackend() {
3362
3374
  böylece ham cevap + önekli cevap çift gönderimi olmaz. */
3363
3375
  const cjob =
3364
3376
  ev.type === 'done' || ev.type === 'error' ? cronAnswerPendingTake(ev.sessionId) : null;
3377
+ /* cron turu bitti → kuyruktaki sıradaki işi başlat (tek tek koşarlar) */
3378
+ if (ev.type === 'done' || ev.type === 'error') cronJobFinished(ev.sessionId);
3365
3379
  // WhatsApp oturumlarının son cevabını geri gönder (metin + opsiyonel ses)
3366
3380
  if ((ev.type === 'done' || ev.type === 'error') && !cjob && wa && wa.connected) {
3367
3381
  const wajid = waReplyJid(ev.sessionId);
@@ -3383,7 +3397,7 @@ function reloadBackend() {
3383
3397
  return;
3384
3398
  }
3385
3399
  const voice = await synthesizeSpeech(txt);
3386
- if (voice) await wa.sendAudio(wajid, voice).catch(() => {});
3400
+ if (voice && voice.audio) await wa.sendAudio(wajid, voice.audio).catch(() => {});
3387
3401
  }
3388
3402
  }
3389
3403
  } catch {}
@@ -7035,31 +7049,108 @@ function cronMirrorTargets(cronSid) {
7035
7049
  return out;
7036
7050
  }
7037
7051
 
7038
- function cronFire(job) {
7039
- /* BEAST FINANCE günlük rutin: plan/review job'ları trader'a özel gönderilir */
7040
- if (job && (job.kind === 'finance-plan' || job.kind === 'finance-review')) {
7041
- try { finDailyRoutine(job.kind === 'finance-plan' ? 'plan' : 'review'); } catch {}
7042
- cronEmit();
7052
+ /* ---------- CRON SIRASI (kuyruk) ----------
7053
+ Aynı dakikada birden fazla iş tetiklenebilir. İşler AYNI taban oturumda
7054
+ koştuğu için hepsini birden göndermek engine'in steer tamponunda birleşmeye
7055
+ ve cron cevap kaydının üst üste yazılmasına yol açıyordu. Artık işler
7056
+ sıraya girer: BİRİ bitmeden (done/error) diğeri gönderilmez; oturum
7057
+ kullanıcı turuyla meşgulse sıradaki iş bekler. */
7058
+ const cronQueue = [];
7059
+ let cronRunning = false;
7060
+ let cronRunningSid = '';
7061
+ let cronPumpTimer = null;
7062
+ let cronWatchdog = null;
7063
+
7064
+ function cronPumpSoon(ms) {
7065
+ if (cronPumpTimer) return; // tek zamanlayıcı — üst üste birikmez
7066
+ cronPumpTimer = setTimeout(() => {
7067
+ cronPumpTimer = null;
7068
+ cronPump();
7069
+ }, ms);
7070
+ }
7071
+
7072
+ /* Emniyet bekçisi: çalışıyor sanılan iş için done/error olayı kaçarsa
7073
+ (oturum artık meşgul değilse) kuyruğu açar; iş hâlâ koşuyorsa izler. */
7074
+ function cronArmWatchdog() {
7075
+ if (cronWatchdog) clearTimeout(cronWatchdog);
7076
+ cronWatchdog = setTimeout(() => {
7077
+ cronWatchdog = null;
7078
+ if (!cronRunning) return;
7079
+ let busy = false;
7080
+ try { busy = !!cronRunningSid && engine.isBusy(cronRunningSid); } catch {}
7081
+ if (busy) { cronArmWatchdog(); return; }
7082
+ cronRunning = false;
7083
+ cronRunningSid = '';
7084
+ if (cronQueue.length) cronPumpSoon(50);
7085
+ }, 60000);
7086
+ }
7087
+
7088
+ /* sıradaki cron işini (oturum boşsa) başlatır */
7089
+ function cronPump() {
7090
+ if (cronRunning || !cronQueue.length) return;
7091
+ const job = cronQueue.shift();
7092
+ let sid = '';
7093
+ try { sid = cronBaseSession(); } catch {}
7094
+ if (!sid || !engine) {
7095
+ cronQueue.unshift(job); // oturum yok — sonra tekrar dene
7096
+ cronPumpSoon(5000);
7043
7097
  return;
7044
7098
  }
7099
+ if (engine.isBusy(sid)) {
7100
+ cronQueue.unshift(job); // kullanıcı/başka tur koşuyor — bekle
7101
+ cronPumpSoon(3000);
7102
+ return;
7103
+ }
7104
+ cronRunning = true;
7105
+ cronRunningSid = String(sid);
7106
+ cronAnswerPendingSet(sid, job);
7107
+ let sent = false;
7045
7108
  try {
7046
- /* OTURUMSUZ ÇALIŞTIRMA: taban oturum WA → TG → DC → masaüstü sırasıyla
7047
- seçilir; cevap done olayında TÜM bağlı entegrasyonlara yansıtılır. */
7048
- const sid = cronBaseSession();
7049
- cronAnswerPendingSet(sid, job);
7050
- const sent = engine.send(sid, {
7109
+ sent = engine.send(sid, {
7051
7110
  text: `[cron: ${job.name}]\n${job.prompt}`,
7052
7111
  });
7053
- toastNotify(
7054
- `Cron: ${job.name}`,
7055
- isReminderJob(job) ? 'Hatırlatma zamanı geldi' : String(job.prompt || '').slice(0, 160),
7056
- 'cron'
7057
- );
7058
- if (!sent) cronAnswerPendingDrop(sid); // gönderilemedi — bayat bekleme bırakma
7059
7112
  } catch {}
7113
+ if (!sent) {
7114
+ /* gönderilemedi (ör. /stop kilidi) — kaydı bırakma, sıradakine geç */
7115
+ cronRunning = false;
7116
+ cronRunningSid = '';
7117
+ cronAnswerPendingDrop(sid);
7118
+ cronPumpSoon(1000);
7119
+ cronEmit();
7120
+ return;
7121
+ }
7122
+ toastNotify(
7123
+ `Cron: ${job.name}`,
7124
+ isReminderJob(job) ? 'Hatırlatma zamanı geldi' : String(job.prompt || '').slice(0, 160),
7125
+ 'cron'
7126
+ );
7127
+ cronArmWatchdog();
7060
7128
  cronEmit();
7061
7129
  }
7062
7130
 
7131
+ /* iş turu bitti (done/error) — çalışan işi kapat, sıradakini başlat */
7132
+ function cronJobFinished(sid) {
7133
+ if (cronRunning && String(sid) === cronRunningSid) {
7134
+ cronRunning = false;
7135
+ cronRunningSid = '';
7136
+ }
7137
+ if (cronQueue.length) cronPumpSoon(50); // ctrls temizlenmesini bekle
7138
+ }
7139
+
7140
+ function cronFire(job) {
7141
+ /* BEAST FINANCE günlük rutin: plan/review job'ları trader'a özel gönderilir */
7142
+ if (job && (job.kind === 'finance-plan' || job.kind === 'finance-review')) {
7143
+ try { finDailyRoutine(job.kind === 'finance-plan' ? 'plan' : 'review'); } catch {}
7144
+ cronEmit();
7145
+ return;
7146
+ }
7147
+ /* OTURUMSUZ ÇALIŞTIRMA: taban oturum WA → TG → DC → masaüstü sırasıyla
7148
+ seçilir; işler kuyrukta TEK TEK koşar, cevap done olayında TÜM bağlı
7149
+ entegrasyonlara yansıtılır. */
7150
+ cronQueue.push(job);
7151
+ cronPump();
7152
+ }
7153
+
7063
7154
  /* İzleyici tetiklendiğinde ilgili sohbete kullanıcı mesajı gibi düşer */
7064
7155
  function watcherFire(w, value) {
7065
7156
  try {
@@ -12778,11 +12869,14 @@ ipcMain.handle('wa:sessions', () => [...waChats.values()]);
12778
12869
 
12779
12870
  ipcMain.handle('wa:tts:get', () => settings.waTts || {});
12780
12871
  ipcMain.handle('wa:tts:set', (_e, cfg) => {
12872
+ const eng = String((cfg && cfg.engine) || 'edge');
12873
+ const pv = String((cfg && cfg.piperVoice) || '').trim();
12781
12874
  settings.waTts = {
12782
12875
  enabled: !!(cfg && cfg.enabled),
12783
- /* motor: 'edge' (ücretsiz yerel, varsayılan) | 'openai' (OpenAI-uyumlu API) */
12784
- engine: (cfg && cfg.engine) === 'openai' ? 'openai' : 'edge',
12876
+ /* motor: 'edge' (ücretsiz bulut, varsayılan) | 'piper' (yerel/offline) | 'openai' (API) */
12877
+ engine: ['edge', 'piper', 'openai'].includes(eng) ? eng : 'edge',
12785
12878
  edgeVoice: String((cfg && cfg.edgeVoice) || 'tr-TR-AhmetNeural').trim(),
12879
+ piperVoice: piper.VOICES[pv] ? pv : piper.DEFAULT_VOICE,
12786
12880
  chatAutoSpeak: !!(cfg && cfg.chatAutoSpeak),
12787
12881
  baseUrl: String((cfg && cfg.baseUrl) || '').trim(),
12788
12882
  key: String((cfg && cfg.key) || '').trim(),
@@ -12793,6 +12887,15 @@ ipcMain.handle('wa:tts:set', (_e, cfg) => {
12793
12887
  return settings.waTts;
12794
12888
  });
12795
12889
 
12890
+ /* PIPER (yerel/offline TTS): durum + elle indirme (runtime + ses modeli) */
12891
+ ipcMain.handle('piper:status', (_e, voice) => {
12892
+ try { return piper.status(String(voice || '')); } catch { return { installed: false, runtime: false, voice: false, installing: false }; }
12893
+ });
12894
+ ipcMain.handle('piper:install', async (_e, voice) => {
12895
+ const r = await piper.install(String(voice || ''));
12896
+ return { ...r, status: piper.status(String(voice || '')) };
12897
+ });
12898
+
12796
12899
  /* CHAT TTS: masaüstü sohbetinde ajanın son yazısını seslendirir (base64 mp3).
12797
12900
  chatAutoSpeak açıkken renderer 'done' olayında bu kanalı çağırır. */
12798
12901
  ipcMain.handle('tts:synthesize', async (_e, text) => {
@@ -12800,13 +12903,14 @@ ipcMain.handle('tts:synthesize', async (_e, text) => {
12800
12903
  if (!cfg.enabled) return { ok: false, error: 'tts kapalı' };
12801
12904
  const t = String(text || '').trim();
12802
12905
  if (!t) return { ok: false, error: 'boş metin' };
12803
- const audio = await synthesizeSpeech(t.slice(0, 4000));
12804
- if (!audio) {
12906
+ const out = await synthesizeSpeech(t.slice(0, 4000));
12907
+ if (!out || !out.audio) {
12805
12908
  waLog('tts chat seslendirilemedi — motor: ' + (cfg.engine || 'edge'));
12806
12909
  return { ok: false, error: 'seslendirilemedi' };
12807
12910
  }
12808
- waLog('tts chat ok: ' + audio.length + ' bayt, motor: ' + (cfg.engine || 'edge') + ', ses: ' + (cfg.edgeVoice || '-'));
12809
- return { ok: true, audioB64: audio.toString('base64'), mime: 'audio/mpeg' };
12911
+ const ses = cfg.engine === 'piper' ? (cfg.piperVoice || piper.DEFAULT_VOICE) : (cfg.edgeVoice || '-');
12912
+ waLog('tts chat ok: ' + out.audio.length + ' bayt, motor: ' + (cfg.engine || 'edge') + ', ses: ' + ses);
12913
+ return { ok: true, audioB64: out.audio.toString('base64'), mime: out.mime || 'audio/mpeg' };
12810
12914
  });
12811
12915
 
12812
12916
  /* ---------- Telegram IPC (FEATURE 3) ---------- */
package/src/preload.js CHANGED
@@ -203,6 +203,8 @@ contextBridge.exposeInMainWorld('beast', {
203
203
  setWaLockdown: (v) => ipcRenderer.invoke('wa:lockdown:set', v),
204
204
  waGetTts: () => ipcRenderer.invoke('wa:tts:get'),
205
205
  waSetTts: (cfg) => ipcRenderer.invoke('wa:tts:set', cfg),
206
+ piperStatus: (voice) => ipcRenderer.invoke('piper:status', voice),
207
+ piperInstall: (voice) => ipcRenderer.invoke('piper:install', voice),
206
208
  waGetGroups: () => ipcRenderer.invoke('wa:groups:get'),
207
209
  waSetGroups: (cfg) => ipcRenderer.invoke('wa:groups:set', cfg),
208
210
  tgGetStatus: () => ipcRenderer.invoke('tg:status:get'),
@@ -268,9 +268,11 @@
268
268
  tts_save: 'TTS Kaydet',
269
269
  tts_test: 'Test sesi çal',
270
270
  stt_download_now: 'Şimdi indir / hazırla',
271
- tts_note: 'Edge TTS: ücretsiz yerel motor — anahtar gerekmez (Ahmet/Emel vb.). OpenAI motoru: uyumlu speech API (tts-1, tts-1-hd; sesler alloy, echo, fable, onyx, nova, shimmer).',
271
+ tts_note: 'Edge TTS: ücretsiz bulut motoru — anahtar gerekmez (Ahmet/Emel vb.). Piper: tamamen yerel/offline neural motor (ilk kullanımda ~80MB indirir). OpenAI motoru: uyumlu speech API (tts-1, tts-1-hd; sesler alloy, echo, fable, onyx, nova, shimmer).',
272
272
  tts_engine: 'Motor',
273
273
  tts_edge_voice: 'Edge sesi',
274
+ tts_piper_voice: 'Piper sesi',
275
+ tts_piper_download: 'Piper\'ı indir / hazırla',
274
276
  tts_chat_auto: 'Chat\'te ajanın yazdıklarını otomatik seslendir',
275
277
  tts_on: 'TTS açık — cevaplar sesli de gider',
276
278
  tts_off: 'TTS kapalı',
@@ -992,9 +994,11 @@
992
994
  tts_save: 'Save TTS',
993
995
  tts_test: 'Play test sound',
994
996
  stt_download_now: 'Download / prepare now',
995
- tts_note: 'Edge TTS: free local engine — no key needed (Ahmet/Emel etc.). OpenAI engine: compatible speech API (tts-1, tts-1-hd; voices alloy, echo, fable, onyx, nova, shimmer).',
997
+ tts_note: 'Edge TTS: free cloud engine — no key needed (Ahmet/Emel etc.). Piper: fully local/offline neural engine (downloads ~80MB on first use). OpenAI engine: compatible speech API (tts-1, tts-1-hd; voices alloy, echo, fable, onyx, nova, shimmer).',
996
998
  tts_engine: 'Engine',
997
999
  tts_edge_voice: 'Edge voice',
1000
+ tts_piper_voice: 'Piper voice',
1001
+ tts_piper_download: 'Download / prepare Piper',
998
1002
  tts_chat_auto: 'Auto-speak agent replies in chat',
999
1003
  tts_on: 'TTS on — replies also go as voice',
1000
1004
  tts_off: 'TTS off',
@@ -2580,6 +2580,14 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2580
2580
  ]
2581
2581
  .map(([v, n]) => `<option value="${v}">${n}</option>`)
2582
2582
  .join('');
2583
+ const piperOpts = [
2584
+ ['tr_TR-fahrettin-medium', 'Fahrettin — Türkçe (erkek)'],
2585
+ ['tr_TR-fettah-medium', 'Fettah — Türkçe (erkek)'],
2586
+ ['en_US-lessac-medium', 'Lessac — English (male)'],
2587
+ ['en_US-amy-medium', 'Amy — English (female)'],
2588
+ ]
2589
+ .map(([v, n]) => `<option value="${v}">${n}</option>`)
2590
+ .join('');
2583
2591
  pane.innerHTML =
2584
2592
  '<h2>' + _t('tts_h2') + '</h2><div class="sub">' + _t('tts_sub') + '</div>' +
2585
2593
  '<div class="sub" style="margin:10px 0 4px;font-weight:700;color:var(--accent)">STT (Ses → Yazı)</div>' +
@@ -2590,11 +2598,18 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2590
2598
  <label class="lock-row"><input type="checkbox" id="ttsOn" /><span>${_t('tts_active')}</span></label>
2591
2599
  <label style="grid-column:1">${_t('tts_engine')}</label>
2592
2600
  <select id="ttsEngine" class="inp" style="grid-column:2/4">
2593
- <option value="edge">Edge TTS — yerel & ücretsiz (Ahmet/Emel…)</option>
2601
+ <option value="edge">Edge TTS — ücretsiz bulut (Ahmet/Emel…)</option>
2602
+ <option value="piper">Piper — yerel & offline (Fahrettin/Fettah…)</option>
2594
2603
  <option value="openai">OpenAI-uyumlu API (tts-1, ses: alloy…)</option>
2595
2604
  </select>
2596
2605
  <label style="grid-column:1">${_t('tts_edge_voice')}</label>
2597
2606
  <select id="ttsEdgeVoice" class="inp" style="grid-column:2/4">${edgeOpts}</select>
2607
+ <label style="grid-column:1">${_t('tts_piper_voice')}</label>
2608
+ <select id="ttsPiperVoice" class="inp" style="grid-column:2/4">${piperOpts}</select>
2609
+ <span id="piperStatusRow" style="grid-column:1/4;display:flex;gap:10px;align-items:center">
2610
+ <span class="sub" id="piperStatusTxt" style="margin:0"></span>
2611
+ <button id="piperDlBtn" class="btn ghost">${_t('tts_piper_download')}</button>
2612
+ </span>
2598
2613
  <label class="lock-row" style="grid-column:1/4"><input type="checkbox" id="ttsChatAuto" /><span>${_t('tts_chat_auto')}</span></label>
2599
2614
  <span id="ttsOpenaiWrap" style="display:contents">
2600
2615
  <input id="ttsUrl" class="inp" placeholder="https://api.openai.com/v1" autocomplete="off" />
@@ -2608,25 +2623,62 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2608
2623
  '<div class="sub" style="margin-top:8px">' + _t('tts_note') + '</div>';
2609
2624
 
2610
2625
  const syncEngineUi = () => {
2611
- const isEdge = $('#ttsEngine').value === 'edge';
2626
+ const eng = $('#ttsEngine').value;
2627
+ const isEdge = eng === 'edge';
2628
+ const isPiper = eng === 'piper';
2612
2629
  $('#ttsEdgeVoice').disabled = !isEdge;
2613
- $('#ttsOpenaiWrap').style.opacity = isEdge ? '0.35' : '1';
2614
- $('#ttsOpenaiWrap').querySelectorAll('input').forEach((i) => (i.disabled = isEdge));
2630
+ $('#ttsPiperVoice').disabled = !isPiper;
2631
+ $('#piperDlBtn').disabled = !isPiper;
2632
+ $('#piperStatusTxt').style.opacity = isPiper ? '1' : '0.45';
2633
+ $('#ttsOpenaiWrap').style.opacity = eng === 'openai' ? '1' : '0.35';
2634
+ $('#ttsOpenaiWrap').querySelectorAll('input').forEach((i) => (i.disabled = eng !== 'openai'));
2615
2635
  };
2616
2636
 
2617
2637
  try {
2618
2638
  const tts = await beast.waGetTts();
2619
2639
  $('#ttsOn').checked = !!tts.enabled;
2620
- $('#ttsEngine').value = tts.engine === 'openai' ? 'openai' : 'edge';
2640
+ $('#ttsEngine').value = ['edge', 'piper', 'openai'].includes(tts.engine) ? tts.engine : 'edge';
2621
2641
  $('#ttsEdgeVoice').value = tts.edgeVoice || 'tr-TR-AhmetNeural';
2642
+ $('#ttsPiperVoice').value = tts.piperVoice || 'tr_TR-fahrettin-medium';
2622
2643
  $('#ttsChatAuto').checked = !!tts.chatAutoSpeak;
2623
2644
  $('#ttsUrl').value = tts.baseUrl || '';
2624
2645
  $('#ttsKey').value = tts.key || '';
2625
2646
  $('#ttsModel').value = tts.model || 'tts-1';
2626
2647
  $('#ttsVoice').value = tts.voice || 'alloy';
2627
2648
  } catch {}
2649
+
2650
+ /* Piper durum satırı: runtime/ses modeli hazır mı (indirilince güncellenir) */
2651
+ const refreshPiperStatus = async () => {
2652
+ const v = $('#ttsPiperVoice') ? $('#ttsPiperVoice').value : '';
2653
+ try {
2654
+ const st = await beast.piperStatus(v);
2655
+ const el = $('#piperStatusTxt');
2656
+ if (el && st) {
2657
+ el.textContent = 'Piper: ' + (st.installed
2658
+ ? 'hazır — yerel/offline'
2659
+ : st.installing
2660
+ ? 'indiriliyor…'
2661
+ : st.runtime
2662
+ ? 'ses modeli indirilecek (ilk kullanımda otomatik)'
2663
+ : 'runtime + ses modeli indirilecek (ilk kullanımda otomatik)');
2664
+ }
2665
+ return st;
2666
+ } catch { return null; }
2667
+ };
2668
+
2628
2669
  syncEngineUi();
2629
- $('#ttsEngine').addEventListener('change', syncEngineUi);
2670
+ await refreshPiperStatus();
2671
+ $('#ttsEngine').addEventListener('change', () => { syncEngineUi(); refreshPiperStatus(); });
2672
+ $('#ttsPiperVoice').addEventListener('change', refreshPiperStatus);
2673
+ $('#piperDlBtn').addEventListener('click', async () => {
2674
+ const v = $('#ttsPiperVoice').value;
2675
+ toast('Piper indiriliyor… (ilk seferde ~80MB)');
2676
+ const poll = setInterval(refreshPiperStatus, 4000);
2677
+ const r = await beast.piperInstall(v).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
2678
+ clearInterval(poll);
2679
+ await refreshPiperStatus();
2680
+ toast(r && r.ok ? 'Piper hazır ✓ — artık çevrimdışı seslendirme yapılır' : 'Piper indirilemedi: ' + ((r && r.error) || '?'));
2681
+ });
2630
2682
 
2631
2683
  /* STT durum satırı: yenile + şimdi indir (yüklenirken 3 sn'de bir güncellenir) */
2632
2684
  const refreshSttStatus = async () => {
@@ -2650,6 +2702,7 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2650
2702
  enabled: $('#ttsOn').checked,
2651
2703
  engine: $('#ttsEngine').value,
2652
2704
  edgeVoice: $('#ttsEdgeVoice').value,
2705
+ piperVoice: $('#ttsPiperVoice').value,
2653
2706
  chatAutoSpeak: $('#ttsChatAuto').checked,
2654
2707
  baseUrl: $('#ttsUrl').value.trim(),
2655
2708
  key: $('#ttsKey').value.trim(),
@@ -2664,8 +2717,14 @@ async function renderTtsPane() { const pane = $('#tab-tts');
2664
2717
  });
2665
2718
  $('#ttsTest').addEventListener('click', async () => {
2666
2719
  /* motor + IPC + çalma zincirini uçtan uca dener — sorun neredeyse görünür */
2667
- toast('TTS test ediliyor…');
2668
- const r = await beast.ttsSpeak('Merhaba kanka, Edge TTS testi. Ben Beast.').catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
2720
+ const eng = $('#ttsEngine').value;
2721
+ const line = eng === 'piper'
2722
+ ? 'Merhaba kanka, Piper yerel TTS testi. Ben Beast.'
2723
+ : eng === 'openai'
2724
+ ? 'Merhaba kanka, OpenAI TTS testi. Ben Beast.'
2725
+ : 'Merhaba kanka, Edge TTS testi. Ben Beast.';
2726
+ toast(eng === 'piper' ? 'TTS test ediliyor… (Piper ilk seferde indiriyorsa uzun sürebilir)' : 'TTS test ediliyor…');
2727
+ const r = await beast.ttsSpeak(line).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
2669
2728
  if (!(r && r.ok)) { toast('TTS test HATA: ' + ((r && r.error) || '?')); return; }
2670
2729
  try {
2671
2730
  await playTtsB64(r);