opencode-smart-voice-notify 1.0.8 → 1.0.9

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,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
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",
@@ -44,4 +44,4 @@
44
44
  "peerDependencies": {
45
45
  "@opencode-ai/plugin": "^1.0.0"
46
46
  }
47
- }
47
+ }
package/util/linux.js ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Linux Platform Compatibility Module
3
+ *
4
+ * Provides Linux-specific implementations for:
5
+ * - Wake monitor from sleep (X11 and Wayland)
6
+ * - Get current system volume (PulseAudio/PipeWire and ALSA)
7
+ * - Force system volume up (PulseAudio/PipeWire and ALSA)
8
+ * - Play audio files (PulseAudio and ALSA)
9
+ *
10
+ * Dependencies (optional - graceful fallback if missing):
11
+ * - x11-xserver-utils (for xset on X11)
12
+ * - pulseaudio-utils or pipewire-pulse (for pactl)
13
+ * - alsa-utils (for amixer, aplay, paplay)
14
+ *
15
+ * @module util/linux
16
+ */
17
+
18
+ /**
19
+ * Creates a Linux platform utilities instance
20
+ * @param {object} params - { $: shell runner, debugLog: logging function }
21
+ * @returns {object} Linux platform API
22
+ */
23
+ export const createLinuxPlatform = ({ $, debugLog = () => {} }) => {
24
+
25
+ // ============================================================
26
+ // DISPLAY SESSION DETECTION
27
+ // ============================================================
28
+
29
+ /**
30
+ * Detect if running under Wayland
31
+ * @returns {boolean}
32
+ */
33
+ const isWayland = () => {
34
+ return !!process.env.WAYLAND_DISPLAY;
35
+ };
36
+
37
+ /**
38
+ * Detect if running under X11
39
+ * @returns {boolean}
40
+ */
41
+ const isX11 = () => {
42
+ return !!process.env.DISPLAY && !isWayland();
43
+ };
44
+
45
+ /**
46
+ * Get the current session type
47
+ * @returns {'x11' | 'wayland' | 'tty' | 'unknown'}
48
+ */
49
+ const getSessionType = () => {
50
+ const sessionType = process.env.XDG_SESSION_TYPE;
51
+ if (sessionType === 'x11' || sessionType === 'wayland' || sessionType === 'tty') {
52
+ return sessionType;
53
+ }
54
+ if (isWayland()) return 'wayland';
55
+ if (isX11()) return 'x11';
56
+ return 'unknown';
57
+ };
58
+
59
+ // ============================================================
60
+ // WAKE MONITOR
61
+ // ============================================================
62
+
63
+ /**
64
+ * Wake monitor using X11 DPMS (works on X11 and often XWayland)
65
+ * @returns {Promise<boolean>} Success status
66
+ */
67
+ const wakeMonitorX11 = async () => {
68
+ if (!$) return false;
69
+ try {
70
+ await $`xset dpms force on`.quiet();
71
+ debugLog('wakeMonitor: X11 xset dpms force on succeeded');
72
+ return true;
73
+ } catch (e) {
74
+ debugLog(`wakeMonitor: X11 xset failed: ${e.message}`);
75
+ return false;
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Wake monitor using GNOME D-Bus (for GNOME on Wayland)
81
+ * Triggers a brightness step which wakes the display
82
+ * @returns {Promise<boolean>} Success status
83
+ */
84
+ const wakeMonitorGnomeDBus = async () => {
85
+ if (!$) return false;
86
+ try {
87
+ await $`gdbus call --session --dest org.gnome.SettingsDaemon.Power --object-path /org/gnome/SettingsDaemon/Power --method org.gnome.SettingsDaemon.Power.Screen.StepUp`.quiet();
88
+ debugLog('wakeMonitor: GNOME D-Bus StepUp succeeded');
89
+ return true;
90
+ } catch (e) {
91
+ debugLog(`wakeMonitor: GNOME D-Bus failed: ${e.message}`);
92
+ return false;
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Wake monitor from sleep/DPMS standby
98
+ * Tries multiple methods with graceful fallback:
99
+ * 1. X11 xset (works on X11 and XWayland)
100
+ * 2. GNOME D-Bus (works on GNOME Wayland)
101
+ *
102
+ * @returns {Promise<boolean>} True if any method succeeded
103
+ */
104
+ const wakeMonitor = async () => {
105
+ // Try X11 method first (most compatible, works on XWayland too)
106
+ if (await wakeMonitorX11()) return true;
107
+
108
+ // Try GNOME Wayland D-Bus method
109
+ if (await wakeMonitorGnomeDBus()) return true;
110
+
111
+ debugLog('wakeMonitor: all methods failed');
112
+ return false;
113
+ };
114
+
115
+ // ============================================================
116
+ // VOLUME CONTROL - PULSEAUDIO / PIPEWIRE
117
+ // ============================================================
118
+
119
+ /**
120
+ * Get current volume using PulseAudio/PipeWire (pactl)
121
+ * @returns {Promise<number>} Volume percentage (0-100) or -1 if failed
122
+ */
123
+ const getVolumePulse = async () => {
124
+ if (!$) return -1;
125
+ try {
126
+ const result = await $`pactl get-sink-volume @DEFAULT_SINK@`.quiet();
127
+ const output = result.stdout?.toString() || '';
128
+ // Parse output like: "Volume: front-left: 65536 / 100% / 0.00 dB, ..."
129
+ const match = output.match(/(\d+)%/);
130
+ if (match) {
131
+ const volume = parseInt(match[1], 10);
132
+ debugLog(`getVolume: pactl returned ${volume}%`);
133
+ return volume;
134
+ }
135
+ } catch (e) {
136
+ debugLog(`getVolume: pactl failed: ${e.message}`);
137
+ }
138
+ return -1;
139
+ };
140
+
141
+ /**
142
+ * Set volume using PulseAudio/PipeWire (pactl)
143
+ * @param {number} volume - Volume percentage (0-100)
144
+ * @returns {Promise<boolean>} Success status
145
+ */
146
+ const setVolumePulse = async (volume) => {
147
+ if (!$) return false;
148
+ try {
149
+ const clampedVolume = Math.max(0, Math.min(100, volume));
150
+ await $`pactl set-sink-volume @DEFAULT_SINK@ ${clampedVolume}%`.quiet();
151
+ debugLog(`setVolume: pactl set to ${clampedVolume}%`);
152
+ return true;
153
+ } catch (e) {
154
+ debugLog(`setVolume: pactl failed: ${e.message}`);
155
+ return false;
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Unmute using PulseAudio/PipeWire (pactl)
161
+ * @returns {Promise<boolean>} Success status
162
+ */
163
+ const unmutePulse = async () => {
164
+ if (!$) return false;
165
+ try {
166
+ await $`pactl set-sink-mute @DEFAULT_SINK@ 0`.quiet();
167
+ debugLog('unmute: pactl succeeded');
168
+ return true;
169
+ } catch (e) {
170
+ debugLog(`unmute: pactl failed: ${e.message}`);
171
+ return false;
172
+ }
173
+ };
174
+
175
+ /**
176
+ * Check if muted using PulseAudio/PipeWire
177
+ * @returns {Promise<boolean|null>} True if muted, false if not, null if failed
178
+ */
179
+ const isMutedPulse = async () => {
180
+ if (!$) return null;
181
+ try {
182
+ const result = await $`pactl get-sink-mute @DEFAULT_SINK@`.quiet();
183
+ const output = result.stdout?.toString() || '';
184
+ // Output: "Mute: yes" or "Mute: no"
185
+ return /yes|true/i.test(output);
186
+ } catch (e) {
187
+ debugLog(`isMuted: pactl failed: ${e.message}`);
188
+ return null;
189
+ }
190
+ };
191
+
192
+ // ============================================================
193
+ // VOLUME CONTROL - ALSA (FALLBACK)
194
+ // ============================================================
195
+
196
+ /**
197
+ * Get current volume using ALSA (amixer)
198
+ * @returns {Promise<number>} Volume percentage (0-100) or -1 if failed
199
+ */
200
+ const getVolumeAlsa = async () => {
201
+ if (!$) return -1;
202
+ try {
203
+ const result = await $`amixer get Master`.quiet();
204
+ const output = result.stdout?.toString() || '';
205
+ // Parse output like: "Front Left: Playback 65536 [75%] [on]"
206
+ const match = output.match(/\[(\d+)%\]/);
207
+ if (match) {
208
+ const volume = parseInt(match[1], 10);
209
+ debugLog(`getVolume: amixer returned ${volume}%`);
210
+ return volume;
211
+ }
212
+ } catch (e) {
213
+ debugLog(`getVolume: amixer failed: ${e.message}`);
214
+ }
215
+ return -1;
216
+ };
217
+
218
+ /**
219
+ * Set volume using ALSA (amixer)
220
+ * @param {number} volume - Volume percentage (0-100)
221
+ * @returns {Promise<boolean>} Success status
222
+ */
223
+ const setVolumeAlsa = async (volume) => {
224
+ if (!$) return false;
225
+ try {
226
+ const clampedVolume = Math.max(0, Math.min(100, volume));
227
+ await $`amixer set Master ${clampedVolume}%`.quiet();
228
+ debugLog(`setVolume: amixer set to ${clampedVolume}%`);
229
+ return true;
230
+ } catch (e) {
231
+ debugLog(`setVolume: amixer failed: ${e.message}`);
232
+ return false;
233
+ }
234
+ };
235
+
236
+ /**
237
+ * Unmute using ALSA (amixer)
238
+ * @returns {Promise<boolean>} Success status
239
+ */
240
+ const unmuteAlsa = async () => {
241
+ if (!$) return false;
242
+ try {
243
+ await $`amixer set Master unmute`.quiet();
244
+ debugLog('unmute: amixer succeeded');
245
+ return true;
246
+ } catch (e) {
247
+ debugLog(`unmute: amixer failed: ${e.message}`);
248
+ return false;
249
+ }
250
+ };
251
+
252
+ /**
253
+ * Check if muted using ALSA
254
+ * @returns {Promise<boolean|null>} True if muted, false if not, null if failed
255
+ */
256
+ const isMutedAlsa = async () => {
257
+ if (!$) return null;
258
+ try {
259
+ const result = await $`amixer get Master`.quiet();
260
+ const output = result.stdout?.toString() || '';
261
+ // Look for [off] or [mute] in output
262
+ return /\[off\]|\[mute\]/i.test(output);
263
+ } catch (e) {
264
+ debugLog(`isMuted: amixer failed: ${e.message}`);
265
+ return null;
266
+ }
267
+ };
268
+
269
+ // ============================================================
270
+ // UNIFIED VOLUME CONTROL (AUTO-DETECT BACKEND)
271
+ // ============================================================
272
+
273
+ /**
274
+ * Get current system volume
275
+ * Tries PulseAudio first, then falls back to ALSA
276
+ * @returns {Promise<number>} Volume percentage (0-100) or -1 if failed
277
+ */
278
+ const getCurrentVolume = async () => {
279
+ // Try PulseAudio/PipeWire first (most common on desktop Linux)
280
+ let volume = await getVolumePulse();
281
+ if (volume >= 0) return volume;
282
+
283
+ // Fallback to ALSA
284
+ volume = await getVolumeAlsa();
285
+ return volume;
286
+ };
287
+
288
+ /**
289
+ * Set system volume
290
+ * Tries PulseAudio first, then falls back to ALSA
291
+ * @param {number} volume - Volume percentage (0-100)
292
+ * @returns {Promise<boolean>} Success status
293
+ */
294
+ const setVolume = async (volume) => {
295
+ // Try PulseAudio/PipeWire first
296
+ if (await setVolumePulse(volume)) return true;
297
+
298
+ // Fallback to ALSA
299
+ return await setVolumeAlsa(volume);
300
+ };
301
+
302
+ /**
303
+ * Unmute system audio
304
+ * Tries PulseAudio first, then falls back to ALSA
305
+ * @returns {Promise<boolean>} Success status
306
+ */
307
+ const unmute = async () => {
308
+ // Try PulseAudio/PipeWire first
309
+ if (await unmutePulse()) return true;
310
+
311
+ // Fallback to ALSA
312
+ return await unmuteAlsa();
313
+ };
314
+
315
+ /**
316
+ * Check if system audio is muted
317
+ * Tries PulseAudio first, then falls back to ALSA
318
+ * @returns {Promise<boolean|null>} True if muted, false if not, null if detection failed
319
+ */
320
+ const isMuted = async () => {
321
+ // Try PulseAudio/PipeWire first
322
+ let muted = await isMutedPulse();
323
+ if (muted !== null) return muted;
324
+
325
+ // Fallback to ALSA
326
+ return await isMutedAlsa();
327
+ };
328
+
329
+ /**
330
+ * Force volume to maximum (unmute + set to 100%)
331
+ * Used to ensure notifications are audible
332
+ * @returns {Promise<boolean>} Success status
333
+ */
334
+ const forceVolume = async () => {
335
+ const unmuted = await unmute();
336
+ const volumeSet = await setVolume(100);
337
+ return unmuted || volumeSet;
338
+ };
339
+
340
+ /**
341
+ * Force volume if below threshold
342
+ * @param {number} threshold - Minimum volume threshold (0-100)
343
+ * @returns {Promise<boolean>} True if volume was forced, false if already adequate
344
+ */
345
+ const forceVolumeIfNeeded = async (threshold = 50) => {
346
+ const currentVolume = await getCurrentVolume();
347
+
348
+ // If we couldn't detect volume, force it to be safe
349
+ if (currentVolume < 0) {
350
+ debugLog('forceVolumeIfNeeded: could not detect volume, forcing');
351
+ return await forceVolume();
352
+ }
353
+
354
+ // Check if already above threshold
355
+ if (currentVolume >= threshold) {
356
+ debugLog(`forceVolumeIfNeeded: volume ${currentVolume}% >= ${threshold}%, no action needed`);
357
+ return false;
358
+ }
359
+
360
+ // Force volume up
361
+ debugLog(`forceVolumeIfNeeded: volume ${currentVolume}% < ${threshold}%, forcing to 100%`);
362
+ return await forceVolume();
363
+ };
364
+
365
+ // ============================================================
366
+ // AUDIO PLAYBACK
367
+ // ============================================================
368
+
369
+ /**
370
+ * Play an audio file using PulseAudio (paplay)
371
+ * @param {string} filePath - Path to audio file
372
+ * @returns {Promise<boolean>} Success status
373
+ */
374
+ const playAudioPulse = async (filePath) => {
375
+ if (!$) return false;
376
+ try {
377
+ await $`paplay ${filePath}`.quiet();
378
+ debugLog(`playAudio: paplay succeeded for ${filePath}`);
379
+ return true;
380
+ } catch (e) {
381
+ debugLog(`playAudio: paplay failed: ${e.message}`);
382
+ return false;
383
+ }
384
+ };
385
+
386
+ /**
387
+ * Play an audio file using ALSA (aplay)
388
+ * Note: aplay only supports WAV files natively
389
+ * @param {string} filePath - Path to audio file
390
+ * @returns {Promise<boolean>} Success status
391
+ */
392
+ const playAudioAlsa = async (filePath) => {
393
+ if (!$) return false;
394
+ try {
395
+ await $`aplay ${filePath}`.quiet();
396
+ debugLog(`playAudio: aplay succeeded for ${filePath}`);
397
+ return true;
398
+ } catch (e) {
399
+ debugLog(`playAudio: aplay failed: ${e.message}`);
400
+ return false;
401
+ }
402
+ };
403
+
404
+ /**
405
+ * Play an audio file
406
+ * Tries PulseAudio (paplay) first, then falls back to ALSA (aplay)
407
+ * @param {string} filePath - Path to audio file
408
+ * @param {number} loops - Number of times to play (default: 1)
409
+ * @returns {Promise<boolean>} Success status
410
+ */
411
+ const playAudioFile = async (filePath, loops = 1) => {
412
+ for (let i = 0; i < loops; i++) {
413
+ // Try PulseAudio first (supports more formats including MP3)
414
+ if (await playAudioPulse(filePath)) continue;
415
+
416
+ // Fallback to ALSA
417
+ if (await playAudioAlsa(filePath)) continue;
418
+
419
+ // Both failed
420
+ debugLog(`playAudioFile: all methods failed for ${filePath}`);
421
+ return false;
422
+ }
423
+ return true;
424
+ };
425
+
426
+ // ============================================================
427
+ // PUBLIC API
428
+ // ============================================================
429
+
430
+ return {
431
+ // Session detection
432
+ isWayland,
433
+ isX11,
434
+ getSessionType,
435
+
436
+ // Wake monitor
437
+ wakeMonitor,
438
+ wakeMonitorX11,
439
+ wakeMonitorGnomeDBus,
440
+
441
+ // Volume control (unified)
442
+ getCurrentVolume,
443
+ setVolume,
444
+ unmute,
445
+ isMuted,
446
+ forceVolume,
447
+ forceVolumeIfNeeded,
448
+
449
+ // Volume control (specific backends)
450
+ pulse: {
451
+ getVolume: getVolumePulse,
452
+ setVolume: setVolumePulse,
453
+ unmute: unmutePulse,
454
+ isMuted: isMutedPulse,
455
+ },
456
+ alsa: {
457
+ getVolume: getVolumeAlsa,
458
+ setVolume: setVolumeAlsa,
459
+ unmute: unmuteAlsa,
460
+ isMuted: isMutedAlsa,
461
+ },
462
+
463
+ // Audio playback
464
+ playAudioFile,
465
+ playAudioPulse,
466
+ playAudioAlsa,
467
+ };
468
+ };
package/util/tts.js CHANGED
@@ -2,6 +2,7 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  import fs from 'fs';
4
4
  import { loadConfig } from './config.js';
5
+ import { createLinuxPlatform } from './linux.js';
5
6
 
6
7
  const platform = os.platform();
7
8
  const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
@@ -121,6 +122,18 @@ export const createTTS = ({ $, client }) => {
121
122
  const config = getTTSConfig();
122
123
  const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
123
124
 
125
+ // Debug logging function (defined early so it can be passed to Linux platform)
126
+ const debugLog = (message) => {
127
+ if (!config.debugLog) return;
128
+ try {
129
+ const timestamp = new Date().toISOString();
130
+ fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
131
+ } catch (e) {}
132
+ };
133
+
134
+ // Initialize Linux platform utilities (only used on Linux)
135
+ const linux = platform === 'linux' ? createLinuxPlatform({ $, debugLog }) : null;
136
+
124
137
  const showToast = async (message, variant = 'info') => {
125
138
  if (!config.enableToast) return;
126
139
  try {
@@ -136,14 +149,6 @@ export const createTTS = ({ $, client }) => {
136
149
  } catch (e) {}
137
150
  };
138
151
 
139
- const debugLog = (message) => {
140
- if (!config.debugLog) return;
141
- try {
142
- const timestamp = new Date().toISOString();
143
- fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
144
- } catch (e) {}
145
- };
146
-
147
152
  /**
148
153
  * Play an audio file using system media player
149
154
  */
@@ -173,7 +178,11 @@ export const createTTS = ({ $, client }) => {
173
178
  for (let i = 0; i < loops; i++) {
174
179
  await $`afplay ${filePath}`.quiet();
175
180
  }
181
+ } else if (platform === 'linux' && linux) {
182
+ // Use the Linux platform module for audio playback
183
+ await linux.playAudioFile(filePath, loops);
176
184
  } else {
185
+ // Generic fallback for other Unix-like systems
177
186
  for (let i = 0; i < loops; i++) {
178
187
  try {
179
188
  await $`paplay ${filePath}`.quiet();
@@ -337,8 +346,15 @@ ${ssml}
337
346
 
338
347
  /**
339
348
  * Check if the system has been idle long enough that the monitor might be asleep.
349
+ * On Linux, we always return true (assume monitor might be asleep) since idle detection
350
+ * varies significantly across desktop environments.
340
351
  */
341
352
  const isMonitorLikelyAsleep = async () => {
353
+ if (platform === 'linux') {
354
+ // On Linux, we can't reliably detect idle time across all DEs
355
+ // Return true to always attempt wake (it's a no-op if already awake)
356
+ return true;
357
+ }
342
358
  if (platform !== 'win32' || !$) return true;
343
359
  try {
344
360
  const idleThreshold = config.idleThresholdSeconds || 60;
@@ -378,6 +394,10 @@ public static class IdleCheck {
378
394
  * Get the current system volume level (0-100).
379
395
  */
380
396
  const getCurrentVolume = async () => {
397
+ // Use Linux platform module
398
+ if (platform === 'linux' && linux) {
399
+ return await linux.getCurrentVolume();
400
+ }
381
401
  if (platform !== 'win32' || !$) return -1;
382
402
  try {
383
403
  const cmd = `
@@ -416,6 +436,9 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
416
436
  await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
417
437
  } else if (platform === 'darwin') {
418
438
  await $`caffeinate -u -t 1`.quiet();
439
+ } else if (platform === 'linux' && linux) {
440
+ // Use the Linux platform module for wake monitor
441
+ await linux.wakeMonitor();
419
442
  }
420
443
  } catch (e) {
421
444
  debugLog(`wakeMonitor error: ${e.message}`);
@@ -439,6 +462,9 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
439
462
  await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
440
463
  } else if (platform === 'darwin') {
441
464
  await $`osascript -e "set volume output volume 100"`.quiet();
465
+ } else if (platform === 'linux' && linux) {
466
+ // Use the Linux platform module for force volume
467
+ await linux.forceVolume();
442
468
  }
443
469
  } catch (e) {
444
470
  debugLog(`forceVolume error: ${e.message}`);