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 +2 -2
- package/util/linux.js +468 -0
- package/util/tts.js +34 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-smart-voice-notify",
|
|
3
|
-
"version": "1.0.
|
|
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}`);
|