opencode-smart-voice-notify 1.0.5 → 1.0.7

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/index.js CHANGED
@@ -1,7 +1,6 @@
1
- import path from 'path';
2
- import os from 'os';
3
1
  import fs from 'fs';
4
- import { loadConfig } from './util/config.js';
2
+ import os from 'os';
3
+ import path from 'path';
5
4
  import { createTTS, getTTSConfig } from './util/tts.js';
6
5
 
7
6
  /**
@@ -183,6 +182,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
183
182
  : config.idleReminderTTSMessages;
184
183
 
185
184
  const reminderMessage = getRandomMessage(reminderMessages);
185
+
186
+ // Check for ElevenLabs API key configuration issues
187
+ // If user hasn't responded (reminder firing) and config is missing, warn about fallback
188
+ if (config.ttsEngine === 'elevenlabs' && (!config.elevenLabsApiKey || config.elevenLabsApiKey.trim() === '')) {
189
+ debugLog('ElevenLabs API key missing during reminder - showing fallback toast');
190
+ await showToast("⚠️ ElevenLabs API Key missing! Falling back to Edge TTS.", "warning", 6000);
191
+ }
186
192
 
187
193
  // Speak the reminder using TTS
188
194
  await tts.wakeMonitor();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI) and intelligent reminder system",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/util/config.js CHANGED
@@ -1,8 +1,8 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { fileURLToPath } from 'url';
5
-
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+
6
6
  /**
7
7
  * Basic JSONC parser that strips single-line and multi-line comments.
8
8
  * @param {string} jsonc
@@ -13,6 +13,17 @@ const parseJSONC = (jsonc) => {
13
13
  return JSON.parse(stripped);
14
14
  };
15
15
 
16
+ /**
17
+ * Helper to format JSON values for the template.
18
+ * @param {any} val
19
+ * @param {number} indent
20
+ * @returns {string}
21
+ */
22
+ const formatJSON = (val, indent = 0) => {
23
+ const json = JSON.stringify(val, null, 4);
24
+ return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
25
+ };
26
+
16
27
  /**
17
28
  * Get the directory where this plugin is installed.
18
29
  * Used to find bundled assets like example.config.jsonc
@@ -26,8 +37,10 @@ const getPluginDir = () => {
26
37
  /**
27
38
  * Generate a comprehensive default configuration file content.
28
39
  * This provides users with ALL available options fully documented.
40
+ * @param {object} overrides - Existing configuration to preserve
41
+ * @param {string} version - Current version to set in config
29
42
  */
30
- const generateDefaultConfig = () => {
43
+ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
31
44
  return `{
32
45
  // ============================================================
33
46
  // OpenCode Smart Voice Notify - Configuration
@@ -43,6 +56,9 @@ const generateDefaultConfig = () => {
43
56
  //
44
57
  // ============================================================
45
58
 
59
+ // Internal version tracking - DO NOT REMOVE
60
+ "_configVersion": "${version}",
61
+
46
62
  // ============================================================
47
63
  // NOTIFICATION MODE SETTINGS (Smart Notification System)
48
64
  // ============================================================
@@ -51,25 +67,25 @@ const generateDefaultConfig = () => {
51
67
  // 'tts-first' - Speak TTS immediately, no sound
52
68
  // 'both' - Play sound AND speak TTS immediately
53
69
  // 'sound-only' - Only play sound, no TTS at all
54
- "notificationMode": "sound-first",
70
+ "notificationMode": "${overrides.notificationMode || 'sound-first'}",
55
71
 
56
72
  // ============================================================
57
73
  // TTS REMINDER SETTINGS (When user doesn't respond to sound)
58
74
  // ============================================================
59
75
 
60
76
  // Enable TTS reminder if user doesn't respond after sound notification
61
- "enableTTSReminder": true,
77
+ "enableTTSReminder": ${overrides.enableTTSReminder !== undefined ? overrides.enableTTSReminder : true},
62
78
 
63
79
  // Delay (in seconds) before TTS reminder fires
64
80
  // Set globally or per-notification type
65
- "ttsReminderDelaySeconds": 30, // Global default
66
- "idleReminderDelaySeconds": 30, // For task completion notifications
67
- "permissionReminderDelaySeconds": 20, // For permission requests (more urgent)
81
+ "ttsReminderDelaySeconds": ${overrides.ttsReminderDelaySeconds !== undefined ? overrides.ttsReminderDelaySeconds : 30}, // Global default
82
+ "idleReminderDelaySeconds": ${overrides.idleReminderDelaySeconds !== undefined ? overrides.idleReminderDelaySeconds : 30}, // For task completion notifications
83
+ "permissionReminderDelaySeconds": ${overrides.permissionReminderDelaySeconds !== undefined ? overrides.permissionReminderDelaySeconds : 20}, // For permission requests (more urgent)
68
84
 
69
85
  // Follow-up reminders if user STILL doesn't respond after first TTS
70
- "enableFollowUpReminders": true,
71
- "maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
72
- "reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
86
+ "enableFollowUpReminders": ${overrides.enableFollowUpReminders !== undefined ? overrides.enableFollowUpReminders : true},
87
+ "maxFollowUpReminders": ${overrides.maxFollowUpReminders !== undefined ? overrides.maxFollowUpReminders : 3}, // Max number of follow-up TTS reminders
88
+ "reminderBackoffMultiplier": ${overrides.reminderBackoffMultiplier !== undefined ? overrides.reminderBackoffMultiplier : 1.5}, // Each follow-up waits longer (30s, 45s, 67s...)
73
89
 
74
90
  // ============================================================
75
91
  // TTS ENGINE SELECTION
@@ -77,10 +93,10 @@ const generateDefaultConfig = () => {
77
93
  // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
78
94
  // 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
79
95
  // 'sapi' - Windows built-in voices (free, offline, robotic)
80
- "ttsEngine": "edge",
96
+ "ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}",
81
97
 
82
98
  // Enable TTS for notifications (falls back to sound files if TTS fails)
83
- "enableTTS": true,
99
+ "enableTTS": ${overrides.enableTTS !== undefined ? overrides.enableTTS : true},
84
100
 
85
101
  // ============================================================
86
102
  // ELEVENLABS SETTINGS (Best Quality - Anime-like Voices)
@@ -92,7 +108,7 @@ const generateDefaultConfig = () => {
92
108
  // 1. Uncomment elevenLabsApiKey and add your key
93
109
  // 2. Change ttsEngine above to "elevenlabs"
94
110
  //
95
- // "elevenLabsApiKey": "YOUR_API_KEY_HERE",
111
+ ${overrides.elevenLabsApiKey ? `"elevenLabsApiKey": "${overrides.elevenLabsApiKey}",` : `// "elevenLabsApiKey": "YOUR_API_KEY_HERE",`}
96
112
 
97
113
  // Voice ID - Recommended cute/anime-like voices:
98
114
  // 'cgSgspJ2msm6clMCkdW9' - Jessica (Playful, Bright, Warm) - RECOMMENDED
@@ -100,15 +116,15 @@ const generateDefaultConfig = () => {
100
116
  // 'jsCqWAovK2LkecY7zXl4' - Freya (Expressive, Confident)
101
117
  // 'EXAVITQu4vr4xnSDxMaL' - Sarah (Soft, Warm)
102
118
  // Browse more at: https://elevenlabs.io/voice-library
103
- "elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
119
+ "elevenLabsVoiceId": "${overrides.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9'}",
104
120
 
105
121
  // Model: 'eleven_turbo_v2_5' (fast, good), 'eleven_multilingual_v2' (highest quality)
106
- "elevenLabsModel": "eleven_turbo_v2_5",
122
+ "elevenLabsModel": "${overrides.elevenLabsModel || 'eleven_turbo_v2_5'}",
107
123
 
108
124
  // Voice tuning (0.0 to 1.0)
109
- "elevenLabsStability": 0.5, // Lower = more expressive, Higher = more consistent
110
- "elevenLabsSimilarity": 0.75, // How closely to match the original voice
111
- "elevenLabsStyle": 0.5, // Style exaggeration (higher = more expressive)
125
+ "elevenLabsStability": ${overrides.elevenLabsStability !== undefined ? overrides.elevenLabsStability : 0.5}, // Lower = more expressive, Higher = more consistent
126
+ "elevenLabsSimilarity": ${overrides.elevenLabsSimilarity !== undefined ? overrides.elevenLabsSimilarity : 0.75}, // How closely to match the original voice
127
+ "elevenLabsStyle": ${overrides.elevenLabsStyle !== undefined ? overrides.elevenLabsStyle : 0.5}, // Style exaggeration (higher = more expressive)
112
128
 
113
129
  // ============================================================
114
130
  // EDGE TTS SETTINGS (Free Neural Voices - Default Engine)
@@ -121,13 +137,13 @@ const generateDefaultConfig = () => {
121
137
  // 'en-US-AriaNeural' - Confident, clear
122
138
  // 'en-GB-SoniaNeural' - British, friendly
123
139
  // 'en-AU-NatashaNeural' - Australian, warm
124
- "edgeVoice": "en-US-AnaNeural",
140
+ "edgeVoice": "${overrides.edgeVoice || 'en-US-JennyNeural'}",
125
141
 
126
142
  // Pitch adjustment: +0Hz to +100Hz (higher = more anime-like)
127
- "edgePitch": "+50Hz",
143
+ "edgePitch": "${overrides.edgePitch || '+0Hz'}",
128
144
 
129
145
  // Speech rate: -50% to +100%
130
- "edgeRate": "+10%",
146
+ "edgeRate": "${overrides.edgeRate || '+10%'}",
131
147
 
132
148
  // ============================================================
133
149
  // SAPI SETTINGS (Windows Built-in - Last Resort Fallback)
@@ -140,16 +156,16 @@ const generateDefaultConfig = () => {
140
156
  // 'Microsoft Zira Desktop' - Female, US English
141
157
  // 'Microsoft David Desktop' - Male, US English
142
158
  // 'Microsoft Hazel Desktop' - Female, UK English
143
- "sapiVoice": "Microsoft Zira Desktop",
159
+ "sapiVoice": "${overrides.sapiVoice || 'Microsoft Zira Desktop'}",
144
160
 
145
161
  // Speech rate: -10 (slowest) to +10 (fastest), 0 is normal
146
- "sapiRate": -1,
162
+ "sapiRate": ${overrides.sapiRate !== undefined ? overrides.sapiRate : -1},
147
163
 
148
164
  // Pitch: 'x-low', 'low', 'medium', 'high', 'x-high'
149
- "sapiPitch": "medium",
165
+ "sapiPitch": "${overrides.sapiPitch || 'medium'}",
150
166
 
151
167
  // Volume: 'silent', 'x-soft', 'soft', 'medium', 'loud', 'x-loud'
152
- "sapiVolume": "loud",
168
+ "sapiVolume": "${overrides.sapiVolume || 'loud'}",
153
169
 
154
170
  // ============================================================
155
171
  // INITIAL TTS MESSAGES (Used immediately or after sound)
@@ -157,22 +173,22 @@ const generateDefaultConfig = () => {
157
173
  // ============================================================
158
174
 
159
175
  // Messages when agent finishes work (task completion)
160
- "idleTTSMessages": [
176
+ "idleTTSMessages": ${formatJSON(overrides.idleTTSMessages || [
161
177
  "All done! Your task has been completed successfully.",
162
178
  "Hey there! I finished working on your request.",
163
179
  "Task complete! Ready for your review whenever you are.",
164
180
  "Good news! Everything is done and ready for you.",
165
181
  "Finished! Let me know if you need anything else."
166
- ],
182
+ ], 4)},
167
183
 
168
184
  // Messages for permission requests
169
- "permissionTTSMessages": [
185
+ "permissionTTSMessages": ${formatJSON(overrides.permissionTTSMessages || [
170
186
  "Attention please! I need your permission to continue.",
171
187
  "Hey! Quick approval needed to proceed with the task.",
172
188
  "Heads up! There is a permission request waiting for you.",
173
189
  "Excuse me! I need your authorization before I can continue.",
174
190
  "Permission required! Please review and approve when ready."
175
- ],
191
+ ], 4)},
176
192
 
177
193
  // ============================================================
178
194
  // TTS REMINDER MESSAGES (More urgent - used after delay if no response)
@@ -180,22 +196,22 @@ const generateDefaultConfig = () => {
180
196
  // ============================================================
181
197
 
182
198
  // Reminder messages when agent finished but user hasn't responded
183
- "idleReminderTTSMessages": [
199
+ "idleReminderTTSMessages": ${formatJSON(overrides.idleReminderTTSMessages || [
184
200
  "Hey, are you still there? Your task has been waiting for review.",
185
201
  "Just a gentle reminder - I finished your request a while ago!",
186
202
  "Hello? I completed your task. Please take a look when you can.",
187
203
  "Still waiting for you! The work is done and ready for review.",
188
204
  "Knock knock! Your completed task is patiently waiting for you."
189
- ],
205
+ ], 4)},
190
206
 
191
207
  // Reminder messages when permission still needed
192
- "permissionReminderTTSMessages": [
208
+ "permissionReminderTTSMessages": ${formatJSON(overrides.permissionReminderTTSMessages || [
193
209
  "Hey! I still need your permission to continue. Please respond!",
194
210
  "Reminder: There is a pending permission request. I cannot proceed without you.",
195
211
  "Hello? I am waiting for your approval. This is getting urgent!",
196
212
  "Please check your screen! I really need your permission to move forward.",
197
213
  "Still waiting for authorization! The task is on hold until you respond."
198
- ],
214
+ ], 4)},
199
215
 
200
216
  // ============================================================
201
217
  // SOUND FILES (For immediate notifications)
@@ -205,77 +221,77 @@ const generateDefaultConfig = () => {
205
221
  // Sound files are automatically copied here on first run
206
222
  // You can replace with your own custom MP3/WAV files
207
223
 
208
- "idleSound": "assets/Soft-high-tech-notification-sound-effect.mp3",
209
- "permissionSound": "assets/Machine-alert-beep-sound-effect.mp3",
224
+ "idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
225
+ "permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
210
226
 
211
227
  // ============================================================
212
228
  // GENERAL SETTINGS
213
229
  // ============================================================
214
230
 
215
231
  // Wake monitor from sleep when notifying (Windows/macOS)
216
- "wakeMonitor": true,
232
+ "wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
217
233
 
218
234
  // Force system volume up if below threshold
219
- "forceVolume": true,
235
+ "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : true},
220
236
 
221
237
  // Volume threshold (0-100): force volume if current level is below this
222
- "volumeThreshold": 50,
238
+ "volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
223
239
 
224
240
  // Show TUI toast notifications in OpenCode terminal
225
- "enableToast": true,
241
+ "enableToast": ${overrides.enableToast !== undefined ? overrides.enableToast : true},
226
242
 
227
243
  // Enable audio notifications (sound files and TTS)
228
- "enableSound": true,
244
+ "enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
229
245
 
230
246
  // Consider monitor asleep after this many seconds of inactivity (Windows only)
231
- "idleThresholdSeconds": 60,
247
+ "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
232
248
 
233
249
  // Enable debug logging to ~/.config/opencode/smart-voice-notify-debug.log
234
250
  // Useful for troubleshooting notification issues
235
- "debugLog": false
236
- }
237
- `;
251
+ "debugLog": ${overrides.debugLog !== undefined ? overrides.debugLog : false}
252
+ }`;
238
253
  };
239
-
240
- /**
241
- * Copy bundled assets (sound files) to the OpenCode config directory.
242
- * @param {string} configDir - The OpenCode config directory path
243
- */
244
- const copyBundledAssets = (configDir) => {
245
- try {
246
- const pluginDir = getPluginDir();
247
- const sourceAssetsDir = path.join(pluginDir, 'assets');
248
- const targetAssetsDir = path.join(configDir, 'assets');
249
-
250
- // Check if source assets exist (they should be bundled with the plugin)
251
- if (!fs.existsSync(sourceAssetsDir)) {
252
- return; // No bundled assets to copy
253
- }
254
-
255
- // Create target assets directory if it doesn't exist
256
- if (!fs.existsSync(targetAssetsDir)) {
257
- fs.mkdirSync(targetAssetsDir, { recursive: true });
258
- }
259
-
260
- // Copy each asset file if it doesn't already exist in target
261
- const assetFiles = fs.readdirSync(sourceAssetsDir);
262
- for (const file of assetFiles) {
263
- const sourcePath = path.join(sourceAssetsDir, file);
264
- const targetPath = path.join(targetAssetsDir, file);
265
-
266
- // Only copy if target doesn't exist (don't overwrite user customizations)
267
- if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
268
- fs.copyFileSync(sourcePath, targetPath);
269
- }
270
- }
271
- } catch (error) {
272
- // Silently fail - assets are optional
273
- }
274
- };
275
-
254
+
255
+ /**
256
+ * Copy bundled assets (sound files) to the OpenCode config directory.
257
+ * @param {string} configDir - The OpenCode config directory path
258
+ */
259
+ const copyBundledAssets = (configDir) => {
260
+ try {
261
+ const pluginDir = getPluginDir();
262
+ const sourceAssetsDir = path.join(pluginDir, 'assets');
263
+ const targetAssetsDir = path.join(configDir, 'assets');
264
+
265
+ // Check if source assets exist (they should be bundled with the plugin)
266
+ if (!fs.existsSync(sourceAssetsDir)) {
267
+ return; // No bundled assets to copy
268
+ }
269
+
270
+ // Create target assets directory if it doesn't exist
271
+ if (!fs.existsSync(targetAssetsDir)) {
272
+ fs.mkdirSync(targetAssetsDir, { recursive: true });
273
+ }
274
+
275
+ // Copy each asset file if it doesn't already exist in target
276
+ const assetFiles = fs.readdirSync(sourceAssetsDir);
277
+ for (const file of assetFiles) {
278
+ const sourcePath = path.join(sourceAssetsDir, file);
279
+ const targetPath = path.join(targetAssetsDir, file);
280
+
281
+ // Only copy if target doesn't exist (don't overwrite user customizations)
282
+ if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
283
+ fs.copyFileSync(sourcePath, targetPath);
284
+ }
285
+ }
286
+ } catch (error) {
287
+ // Silently fail - assets are optional
288
+ }
289
+ };
290
+
276
291
  /**
277
292
  * Loads a configuration file from the OpenCode config directory.
278
293
  * If the file doesn't exist, creates a default config file.
294
+ * Performs version checks and migrates config if necessary.
279
295
  * @param {string} name - Name of the config file (without .jsonc extension)
280
296
  * @param {object} defaults - Default values if file doesn't exist or is invalid
281
297
  * @returns {object}
@@ -284,36 +300,50 @@ export const loadConfig = (name, defaults = {}) => {
284
300
  const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
285
301
  const filePath = path.join(configDir, `${name}.jsonc`);
286
302
 
287
- if (!fs.existsSync(filePath)) {
288
- // Auto-create the default config file
303
+ // Get current version from package.json
304
+ const pluginDir = getPluginDir();
305
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
306
+ const currentVersion = pkg.version;
307
+
308
+ let existingConfig = null;
309
+ if (fs.existsSync(filePath)) {
310
+ try {
311
+ const content = fs.readFileSync(filePath, 'utf-8');
312
+ existingConfig = parseJSONC(content);
313
+ } catch (error) {
314
+ // If file is invalid JSONC, we'll treat it as missing and overwrite
315
+ }
316
+ }
317
+
318
+ // Version check and migration logic
319
+ if (!existingConfig || existingConfig._configVersion !== currentVersion) {
289
320
  try {
290
321
  // Ensure config directory exists
291
322
  if (!fs.existsSync(configDir)) {
292
323
  fs.mkdirSync(configDir, { recursive: true });
293
324
  }
294
325
 
295
- // Write the default config
296
- const defaultConfig = generateDefaultConfig();
297
- fs.writeFileSync(filePath, defaultConfig, 'utf-8');
326
+ // Generate new config content using existing values as overrides
327
+ // This preserves user settings while updating comments and adding new fields
328
+ const newConfigContent = generateDefaultConfig(existingConfig || {}, currentVersion);
329
+ fs.writeFileSync(filePath, newConfigContent, 'utf-8');
298
330
 
299
- // Also copy bundled assets (sound files) to the config directory
331
+ // Also ensure all bundled assets (sound files) are present in the config directory
300
332
  copyBundledAssets(configDir);
301
333
 
302
- // Parse and return the newly created config merged with defaults
303
- const config = parseJSONC(defaultConfig);
304
- return { ...defaults, ...config };
334
+ if (existingConfig) {
335
+ console.log(`[Smart Voice Notify] Config migrated to version ${currentVersion}`);
336
+ } else {
337
+ console.log(`[Smart Voice Notify] Initialized default config at ${filePath}`);
338
+ }
339
+
340
+ // Re-parse the newly written config
341
+ existingConfig = parseJSONC(newConfigContent);
305
342
  } catch (error) {
306
- // If we can't create the file, just return defaults
307
- return defaults;
343
+ // If migration fails, try to return whatever we have or defaults
344
+ return existingConfig || defaults;
308
345
  }
309
346
  }
310
347
 
311
- try {
312
- const content = fs.readFileSync(filePath, 'utf-8');
313
- const config = parseJSONC(content);
314
- return { ...defaults, ...config };
315
- } catch (error) {
316
- // Silently return defaults - don't use console.error as it breaks TUI
317
- return defaults;
318
- }
348
+ return { ...defaults, ...existingConfig };
319
349
  };
package/util/tts.js CHANGED
@@ -20,8 +20,8 @@ export const getTTSConfig = () => {
20
20
  elevenLabsStability: 0.5,
21
21
  elevenLabsSimilarity: 0.75,
22
22
  elevenLabsStyle: 0.5,
23
- edgeVoice: 'en-US-AnaNeural',
24
- edgePitch: '+50Hz',
23
+ edgeVoice: 'en-US-JennyNeural',
24
+ edgePitch: '+0Hz',
25
25
  edgeRate: '+10%',
26
26
  sapiVoice: 'Microsoft Zira Desktop',
27
27
  sapiRate: -1,
@@ -245,7 +245,7 @@ export const createTTS = ({ $, client }) => {
245
245
 
246
246
  const ssml = `<?xml version="1.0" encoding="UTF-8"?>
247
247
  <speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
248
- <voice name="${voice.replace(/'/g, "''")}">
248
+ <voice name="${voice.replace(/"/g, '&quot;')}">
249
249
  <prosody rate="${ratePercent}" pitch="${pitch}" volume="${volume}">
250
250
  ${escapedText}
251
251
  </prosody>
@@ -255,14 +255,27 @@ export const createTTS = ({ $, client }) => {
255
255
  const scriptContent = `
256
256
  Add-Type -AssemblyName System.Speech
257
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()
258
+ try {
259
+ $synth.Rate = ${rate}
260
+ try { $synth.SelectVoice("${voice.replace(/"/g, '""')}") } catch { }
261
+ $ssml = @"
262
+ ${ssml}
263
+ "@
264
+ $synth.SpeakSsml($ssml)
265
+ } catch {
266
+ [Console]::Error.WriteLine($_.Exception.Message)
267
+ exit 1
268
+ } finally {
269
+ if ($synth) { $synth.Dispose() }
270
+ }
263
271
  `;
264
272
  fs.writeFileSync(scriptPath, scriptContent, 'utf-8');
265
- await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.quiet();
273
+ const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.nothrow().quiet();
274
+
275
+ if (result.exitCode !== 0) {
276
+ debugLog(`speakWithSAPI failed with code ${result.exitCode}: ${result.stderr}`);
277
+ return false;
278
+ }
266
279
  return true;
267
280
  } catch (e) {
268
281
  debugLog(`speakWithSAPI error: ${e.message}`);