opencode-smart-voice-notify 1.0.0
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/LICENSE +21 -0
- package/README.md +200 -0
- package/assets/Machine-alert-beep-sound-effect.mp3 +0 -0
- package/assets/Soft-high-tech-notification-sound-effect.mp3 +0 -0
- package/example.config.jsonc +158 -0
- package/index.js +431 -0
- package/package.json +43 -0
- package/util/config.js +182 -0
- package/util/tts.js +447 -0
package/util/tts.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const platform = os.platform();
|
|
7
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads the TTS configuration (shared with the notification plugin)
|
|
11
|
+
* @returns {object}
|
|
12
|
+
*/
|
|
13
|
+
export const getTTSConfig = () => {
|
|
14
|
+
return loadConfig('smart-voice-notify', {
|
|
15
|
+
ttsEngine: 'elevenlabs',
|
|
16
|
+
enableTTS: true,
|
|
17
|
+
elevenLabsApiKey: '',
|
|
18
|
+
elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
|
|
19
|
+
elevenLabsModel: 'eleven_turbo_v2_5',
|
|
20
|
+
elevenLabsStability: 0.5,
|
|
21
|
+
elevenLabsSimilarity: 0.75,
|
|
22
|
+
elevenLabsStyle: 0.5,
|
|
23
|
+
edgeVoice: 'en-US-AnaNeural',
|
|
24
|
+
edgePitch: '+50Hz',
|
|
25
|
+
edgeRate: '+10%',
|
|
26
|
+
sapiVoice: 'Microsoft Zira Desktop',
|
|
27
|
+
sapiRate: -1,
|
|
28
|
+
sapiPitch: 'medium',
|
|
29
|
+
sapiVolume: 'loud',
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// NOTIFICATION MODE & TTS REMINDER SETTINGS
|
|
33
|
+
// ============================================================
|
|
34
|
+
// 'sound-first' - Play sound immediately, TTS reminder after delay (default)
|
|
35
|
+
// 'tts-first' - Speak TTS immediately, no sound
|
|
36
|
+
// 'both' - Play sound AND speak TTS immediately
|
|
37
|
+
// 'sound-only' - Only play sound, no TTS at all
|
|
38
|
+
notificationMode: 'sound-first',
|
|
39
|
+
|
|
40
|
+
// Enable TTS reminder if user doesn't respond after sound notification
|
|
41
|
+
enableTTSReminder: true,
|
|
42
|
+
|
|
43
|
+
// Delay in seconds before TTS reminder (if user hasn't responded)
|
|
44
|
+
// Can be set globally or per-notification type
|
|
45
|
+
ttsReminderDelaySeconds: 30,
|
|
46
|
+
idleReminderDelaySeconds: 30,
|
|
47
|
+
permissionReminderDelaySeconds: 20,
|
|
48
|
+
|
|
49
|
+
// Follow-up reminders (if user still doesn't respond after first TTS)
|
|
50
|
+
enableFollowUpReminders: true,
|
|
51
|
+
maxFollowUpReminders: 3,
|
|
52
|
+
reminderBackoffMultiplier: 1.5, // Each follow-up waits longer (30s, 45s, 67.5s)
|
|
53
|
+
|
|
54
|
+
// ============================================================
|
|
55
|
+
// TTS MESSAGE VARIETY (Initial notifications - randomly selected)
|
|
56
|
+
// ============================================================
|
|
57
|
+
// Messages when agent finishes work
|
|
58
|
+
idleTTSMessages: [
|
|
59
|
+
'All done! Your task has been completed successfully.',
|
|
60
|
+
'Hey there! I finished working on your request.',
|
|
61
|
+
'Task complete! Ready for your review whenever you are.',
|
|
62
|
+
'Good news! Everything is done and ready for you.',
|
|
63
|
+
'Finished! Let me know if you need anything else.'
|
|
64
|
+
],
|
|
65
|
+
// Messages for permission requests
|
|
66
|
+
permissionTTSMessages: [
|
|
67
|
+
'Attention please! I need your permission to continue.',
|
|
68
|
+
'Hey! Quick approval needed to proceed with the task.',
|
|
69
|
+
'Heads up! There is a permission request waiting for you.',
|
|
70
|
+
'Excuse me! I need your authorization before I can continue.',
|
|
71
|
+
'Permission required! Please review and approve when ready.'
|
|
72
|
+
],
|
|
73
|
+
|
|
74
|
+
// ============================================================
|
|
75
|
+
// TTS REMINDER MESSAGES (More urgent/personalized - used after delay)
|
|
76
|
+
// ============================================================
|
|
77
|
+
// Reminder messages when agent finished but user hasn't responded
|
|
78
|
+
idleReminderTTSMessages: [
|
|
79
|
+
'Hey, are you still there? Your task has been waiting for review.',
|
|
80
|
+
'Just a gentle reminder - I finished your request a while ago!',
|
|
81
|
+
'Hello? I completed your task. Please take a look when you can.',
|
|
82
|
+
'Still waiting for you! The work is done and ready for review.',
|
|
83
|
+
'Knock knock! Your completed task is patiently waiting for you.'
|
|
84
|
+
],
|
|
85
|
+
// Reminder messages when permission still needed
|
|
86
|
+
permissionReminderTTSMessages: [
|
|
87
|
+
'Hey! I still need your permission to continue. Please respond!',
|
|
88
|
+
'Reminder: There is a pending permission request. I cannot proceed without you.',
|
|
89
|
+
'Hello? I am waiting for your approval. This is getting urgent!',
|
|
90
|
+
'Please check your screen! I really need your permission to move forward.',
|
|
91
|
+
'Still waiting for authorization! The task is on hold until you respond.'
|
|
92
|
+
],
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// SOUND FILES (Used for immediate notifications)
|
|
96
|
+
// ============================================================
|
|
97
|
+
idleSound: 'asset/Soft-high-tech-notification-sound-effect.mp3',
|
|
98
|
+
permissionSound: 'asset/Machine-alert-beep-sound-effect.mp3',
|
|
99
|
+
|
|
100
|
+
// ============================================================
|
|
101
|
+
// GENERAL SETTINGS
|
|
102
|
+
// ============================================================
|
|
103
|
+
wakeMonitor: true,
|
|
104
|
+
forceVolume: true,
|
|
105
|
+
enableSound: true,
|
|
106
|
+
enableToast: true,
|
|
107
|
+
volumeThreshold: 50,
|
|
108
|
+
idleThresholdSeconds: 60,
|
|
109
|
+
debugLog: false
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates a TTS utility instance
|
|
115
|
+
* @param {object} params - { $, client }
|
|
116
|
+
* @returns {object} TTS API
|
|
117
|
+
*/
|
|
118
|
+
export const createTTS = ({ $, client }) => {
|
|
119
|
+
const config = getTTSConfig();
|
|
120
|
+
const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
|
|
121
|
+
|
|
122
|
+
const debugLog = (message) => {
|
|
123
|
+
if (!config.debugLog) return;
|
|
124
|
+
try {
|
|
125
|
+
const timestamp = new Date().toISOString();
|
|
126
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
127
|
+
} catch (e) {}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Play an audio file using system media player
|
|
132
|
+
*/
|
|
133
|
+
const playAudioFile = async (filePath, loops = 1) => {
|
|
134
|
+
if (!$) {
|
|
135
|
+
debugLog('playAudioFile: shell runner ($) not available');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (platform === 'win32') {
|
|
140
|
+
const cmd = `
|
|
141
|
+
Add-Type -AssemblyName presentationCore
|
|
142
|
+
$player = New-Object System.Windows.Media.MediaPlayer
|
|
143
|
+
$player.Volume = 1.0
|
|
144
|
+
for ($i = 0; $i -lt ${loops}; $i++) {
|
|
145
|
+
$player.Open([Uri]::new('${filePath.replace(/\\/g, '\\\\')}'))
|
|
146
|
+
$player.Play()
|
|
147
|
+
Start-Sleep -Milliseconds 500
|
|
148
|
+
while ($player.Position -lt $player.NaturalDuration.TimeSpan -and $player.HasAudio) {
|
|
149
|
+
Start-Sleep -Milliseconds 100
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
$player.Close()
|
|
153
|
+
`;
|
|
154
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
155
|
+
} else if (platform === 'darwin') {
|
|
156
|
+
for (let i = 0; i < loops; i++) {
|
|
157
|
+
await $`afplay ${filePath}`.quiet();
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
for (let i = 0; i < loops; i++) {
|
|
161
|
+
try {
|
|
162
|
+
await $`paplay ${filePath}`.quiet();
|
|
163
|
+
} catch {
|
|
164
|
+
await $`aplay ${filePath}`.quiet();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
debugLog(`playAudioFile error: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* ElevenLabs Engine (Online, High Quality, Anime-like voices)
|
|
175
|
+
*/
|
|
176
|
+
const speakWithElevenLabs = async (text) => {
|
|
177
|
+
if (!config.elevenLabsApiKey) {
|
|
178
|
+
debugLog('speakWithElevenLabs: No API key configured');
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const { ElevenLabsClient } = await import('@elevenlabs/elevenlabs-js');
|
|
184
|
+
const elClient = new ElevenLabsClient({ apiKey: config.elevenLabsApiKey });
|
|
185
|
+
|
|
186
|
+
const audio = await elClient.textToSpeech.convert(config.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9', {
|
|
187
|
+
text: text,
|
|
188
|
+
model_id: config.elevenLabsModel || 'eleven_turbo_v2_5',
|
|
189
|
+
voice_settings: {
|
|
190
|
+
stability: config.elevenLabsStability ?? 0.5,
|
|
191
|
+
similarity_boost: config.elevenLabsSimilarity ?? 0.75,
|
|
192
|
+
style: config.elevenLabsStyle ?? 0.5,
|
|
193
|
+
use_speaker_boost: true
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const tempFile = path.join(os.tmpdir(), `opencode-tts-${Date.now()}.mp3`);
|
|
198
|
+
const chunks = [];
|
|
199
|
+
for await (const chunk of audio) { chunks.push(chunk); }
|
|
200
|
+
fs.writeFileSync(tempFile, Buffer.concat(chunks));
|
|
201
|
+
|
|
202
|
+
await playAudioFile(tempFile);
|
|
203
|
+
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
204
|
+
return true;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
debugLog(`speakWithElevenLabs error: ${e.message}`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Edge TTS Engine (Free, Neural voices)
|
|
213
|
+
*/
|
|
214
|
+
const speakWithEdgeTTS = async (text) => {
|
|
215
|
+
if (!$) return false;
|
|
216
|
+
try {
|
|
217
|
+
const voice = config.edgeVoice || 'en-US-AnaNeural';
|
|
218
|
+
const pitch = config.edgePitch || '+0Hz';
|
|
219
|
+
const rate = config.edgeRate || '+0%';
|
|
220
|
+
const tempFile = path.join(os.tmpdir(), `opencode-edge-${Date.now()}.mp3`);
|
|
221
|
+
|
|
222
|
+
await $`edge-tts --voice ${voice} --pitch ${pitch} --rate ${rate} --text ${text} --write-media ${tempFile}`.quiet();
|
|
223
|
+
await playAudioFile(tempFile);
|
|
224
|
+
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
225
|
+
return true;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
debugLog(`speakWithEdgeTTS error: ${e.message}`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Windows SAPI Engine (Offline, Built-in)
|
|
234
|
+
*/
|
|
235
|
+
const speakWithSAPI = async (text) => {
|
|
236
|
+
if (platform !== 'win32' || !$) return false;
|
|
237
|
+
const scriptPath = path.join(os.tmpdir(), `opencode-sapi-${Date.now()}.ps1`);
|
|
238
|
+
try {
|
|
239
|
+
const escapedText = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
240
|
+
const voice = config.sapiVoice || 'Microsoft Zira Desktop';
|
|
241
|
+
const rate = Math.max(-10, Math.min(10, config.sapiRate || -1));
|
|
242
|
+
const pitch = config.sapiPitch || 'medium';
|
|
243
|
+
const volume = config.sapiVolume || 'loud';
|
|
244
|
+
const ratePercent = rate >= 0 ? `+${rate * 10}%` : `${rate * 5}%`;
|
|
245
|
+
|
|
246
|
+
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
247
|
+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
|
|
248
|
+
<voice name="${voice.replace(/'/g, "''")}">
|
|
249
|
+
<prosody rate="${ratePercent}" pitch="${pitch}" volume="${volume}">
|
|
250
|
+
${escapedText}
|
|
251
|
+
</prosody>
|
|
252
|
+
</voice>
|
|
253
|
+
</speak>`;
|
|
254
|
+
|
|
255
|
+
const scriptContent = `
|
|
256
|
+
Add-Type -AssemblyName System.Speech
|
|
257
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
258
|
+
$synth.Rate = ${rate}
|
|
259
|
+
try { $synth.SelectVoice('${voice.replace(/'/g, "''")}') } catch {}
|
|
260
|
+
$ssml = @'\\n${ssml}\\n'@
|
|
261
|
+
try { $synth.SpeakSsml($ssml) } catch { $synth.Speak('${text.replace(/'/g, "''")}') }
|
|
262
|
+
$synth.Dispose()
|
|
263
|
+
`;
|
|
264
|
+
fs.writeFileSync(scriptPath, scriptContent, 'utf-8');
|
|
265
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.quiet();
|
|
266
|
+
return true;
|
|
267
|
+
} catch (e) {
|
|
268
|
+
debugLog(`speakWithSAPI error: ${e.message}`);
|
|
269
|
+
return false;
|
|
270
|
+
} finally {
|
|
271
|
+
try { if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath); } catch (e) {}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* macOS Say Engine
|
|
277
|
+
*/
|
|
278
|
+
const speakWithSay = async (text) => {
|
|
279
|
+
if (platform !== 'darwin' || !$) return false;
|
|
280
|
+
try {
|
|
281
|
+
await $`say ${text}`.quiet();
|
|
282
|
+
return true;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
debugLog(`speakWithSay error: ${e.message}`);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check if the system has been idle long enough that the monitor might be asleep.
|
|
291
|
+
*/
|
|
292
|
+
const isMonitorLikelyAsleep = async () => {
|
|
293
|
+
if (platform !== 'win32' || !$) return true;
|
|
294
|
+
try {
|
|
295
|
+
const idleThreshold = config.idleThresholdSeconds || 60;
|
|
296
|
+
const cmd = `
|
|
297
|
+
Add-Type -TypeDefinition @'
|
|
298
|
+
using System;
|
|
299
|
+
using System.Runtime.InteropServices;
|
|
300
|
+
public static class IdleCheck {
|
|
301
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
302
|
+
public struct LASTINPUTINFO {
|
|
303
|
+
public uint cbSize;
|
|
304
|
+
public uint dwTime;
|
|
305
|
+
}
|
|
306
|
+
[DllImport("user32.dll")]
|
|
307
|
+
public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
308
|
+
public static uint GetIdleSeconds() {
|
|
309
|
+
LASTINPUTINFO lii = new LASTINPUTINFO();
|
|
310
|
+
lii.cbSize = (uint)Marshal.SizeOf(lii);
|
|
311
|
+
if (GetLastInputInfo(ref lii)) {
|
|
312
|
+
return (uint)((Environment.TickCount - lii.dwTime) / 1000);
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
'@
|
|
318
|
+
[IdleCheck]::GetIdleSeconds()
|
|
319
|
+
`;
|
|
320
|
+
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
321
|
+
const idleSeconds = parseInt(result.stdout?.toString().trim() || '0', 10);
|
|
322
|
+
return idleSeconds >= idleThreshold;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get the current system volume level (0-100).
|
|
330
|
+
*/
|
|
331
|
+
const getCurrentVolume = async () => {
|
|
332
|
+
if (platform !== 'win32' || !$) return -1;
|
|
333
|
+
try {
|
|
334
|
+
const cmd = `
|
|
335
|
+
$signature = @'
|
|
336
|
+
[DllImport("winmm.dll")]
|
|
337
|
+
public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
338
|
+
'@
|
|
339
|
+
Add-Type -MemberDefinition $signature -Name Win32VolCheck -Namespace Win32 -PassThru | Out-Null
|
|
340
|
+
$vol = 0
|
|
341
|
+
$result = [Win32.Win32VolCheck]::waveOutGetVolume([IntPtr]::Zero, [ref]$vol)
|
|
342
|
+
if ($result -eq 0) {
|
|
343
|
+
$leftVol = $vol -band 0xFFFF
|
|
344
|
+
[Math]::Round(($leftVol / 65535) * 100)
|
|
345
|
+
} else { -1 }
|
|
346
|
+
`;
|
|
347
|
+
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
348
|
+
return parseInt(result.stdout?.toString().trim() || '-1', 10);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
return -1;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Wake Monitor Utility
|
|
356
|
+
*/
|
|
357
|
+
const wakeMonitor = async (force = false) => {
|
|
358
|
+
if (!config.wakeMonitor || !$) return;
|
|
359
|
+
try {
|
|
360
|
+
if (!force) {
|
|
361
|
+
const likelyAsleep = await isMonitorLikelyAsleep();
|
|
362
|
+
if (!likelyAsleep) return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (platform === 'win32') {
|
|
366
|
+
const cmd = `Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern int SendMessage(int hWnd, int hMsg, int wParam, int lParam);' -Name "Win32SendMessage" -Namespace Win32Functions; [Win32Functions.Win32SendMessage]::SendMessage(0xFFFF, 0x0112, 0xF170, -1)`;
|
|
367
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
368
|
+
} else if (platform === 'darwin') {
|
|
369
|
+
await $`caffeinate -u -t 1`.quiet();
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
debugLog(`wakeMonitor error: ${e.message}`);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Force Volume Utility
|
|
378
|
+
*/
|
|
379
|
+
const forceVolume = async (force = false) => {
|
|
380
|
+
if (!config.forceVolume || !$) return;
|
|
381
|
+
try {
|
|
382
|
+
if (!force) {
|
|
383
|
+
const currentVolume = await getCurrentVolume();
|
|
384
|
+
const volumeThreshold = config.volumeThreshold || 50;
|
|
385
|
+
if (currentVolume >= 0 && currentVolume >= volumeThreshold) return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (platform === 'win32') {
|
|
389
|
+
const cmd = `$wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object { $wsh.SendKeys([char]175) }`;
|
|
390
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
391
|
+
} else if (platform === 'darwin') {
|
|
392
|
+
await $`osascript -e "set volume output volume 100"`.quiet();
|
|
393
|
+
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
debugLog(`forceVolume error: ${e.message}`);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Main Speak function with fallback chain
|
|
401
|
+
* Cascade: ElevenLabs -> Edge TTS -> Windows SAPI -> macOS Say -> Sound File
|
|
402
|
+
*/
|
|
403
|
+
const speak = async (message, options = {}) => {
|
|
404
|
+
const activeConfig = { ...config, ...options };
|
|
405
|
+
if (!activeConfig.enableSound) return false;
|
|
406
|
+
|
|
407
|
+
if (activeConfig.enableTTS) {
|
|
408
|
+
let success = false;
|
|
409
|
+
const engine = activeConfig.ttsEngine || 'elevenlabs';
|
|
410
|
+
|
|
411
|
+
if (engine === 'elevenlabs') {
|
|
412
|
+
success = await speakWithElevenLabs(message);
|
|
413
|
+
if (!success) success = await speakWithEdgeTTS(message);
|
|
414
|
+
if (!success) success = await speakWithSAPI(message);
|
|
415
|
+
} else if (engine === 'edge') {
|
|
416
|
+
success = await speakWithEdgeTTS(message);
|
|
417
|
+
if (!success) success = await speakWithSAPI(message);
|
|
418
|
+
} else if (engine === 'sapi') {
|
|
419
|
+
success = await speakWithSAPI(message);
|
|
420
|
+
if (!success) success = await speakWithSay(message);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (success) return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (activeConfig.fallbackSound) {
|
|
427
|
+
const soundPath = path.isAbsolute(activeConfig.fallbackSound)
|
|
428
|
+
? activeConfig.fallbackSound
|
|
429
|
+
: path.join(configDir, activeConfig.fallbackSound);
|
|
430
|
+
await playAudioFile(soundPath, activeConfig.loops || 1);
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
speak,
|
|
437
|
+
announce: async (message, options = {}) => {
|
|
438
|
+
await wakeMonitor();
|
|
439
|
+
await forceVolume();
|
|
440
|
+
return speak(message, options);
|
|
441
|
+
},
|
|
442
|
+
wakeMonitor,
|
|
443
|
+
forceVolume,
|
|
444
|
+
playAudioFile,
|
|
445
|
+
config
|
|
446
|
+
};
|
|
447
|
+
};
|