opencode-smart-voice-notify 1.0.7 → 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/README.md +242 -234
- package/example.config.jsonc +2 -2
- package/index.js +30 -3
- package/package.json +4 -3
- package/util/linux.js +468 -0
- package/util/tts.js +69 -7
package/README.md
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
<!-- Dynamic Header -->
|
|
2
|
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=0:667eea,100:764ba2&height=120§ion=header"/>
|
|
3
|
+
|
|
4
|
+
# OpenCode Smart Voice Notify
|
|
5
|
+
|
|
6
|
+
> **Disclaimer**: This project is not built by the OpenCode team and is not affiliated with [OpenCode](https://opencode.ai) in any way. It is an independent community plugin.
|
|
7
|
+
|
|
8
|
+
A smart voice notification plugin for [OpenCode](https://opencode.ai) with **multiple TTS engines** and an intelligent reminder system.
|
|
9
|
+
|
|
10
|
+
<img width="1456" height="720" alt="image" src="https://github.com/user-attachments/assets/52ccf357-2548-400b-a346-6362f2fc3180" />
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
### Smart TTS Engine Selection
|
|
16
|
+
The plugin automatically tries multiple TTS engines in order, falling back if one fails:
|
|
17
|
+
|
|
12
18
|
1. **ElevenLabs** (Online) - High-quality, anime-like voices with natural expression
|
|
13
|
-
2. **Edge TTS** (Free) - Microsoft's neural voices, no
|
|
19
|
+
2. **Edge TTS** (Free) - Microsoft's neural voices, native Node.js implementation (no Python required)
|
|
14
20
|
3. **Windows SAPI** (Offline) - Built-in Windows speech synthesis
|
|
15
21
|
4. **Local Sound Files** (Fallback) - Plays bundled MP3 files if all TTS fails
|
|
16
22
|
|
|
@@ -25,185 +31,187 @@ The plugin automatically tries multiple TTS engines in order, falling back if on
|
|
|
25
31
|
- Follow-up reminders with exponential backoff
|
|
26
32
|
- Automatic cancellation when user responds
|
|
27
33
|
- Per-notification type delays (permission requests are more urgent)
|
|
34
|
+
- **Smart Quota Handling**: Automatically falls back to free Edge TTS if ElevenLabs quota is exceeded
|
|
28
35
|
|
|
29
36
|
### System Integration
|
|
37
|
+
- **Native Edge TTS**: No external dependencies (Python/pip) required
|
|
30
38
|
- Wake monitor from sleep before notifying
|
|
31
39
|
- Auto-boost volume if too low
|
|
32
40
|
- TUI toast notifications
|
|
33
41
|
- Cross-platform support (Windows, macOS, Linux)
|
|
34
|
-
|
|
35
|
-
## Installation
|
|
36
|
-
|
|
37
|
-
### Option 1: From npm (Recommended)
|
|
38
|
-
|
|
39
|
-
Add to your OpenCode config file (`~/.config/opencode/opencode.json`):
|
|
40
|
-
|
|
41
|
-
```json
|
|
42
|
-
{
|
|
43
|
-
"$schema": "https://opencode.ai/config.json",
|
|
44
|
-
"plugin": ["opencode-smart-voice-notify@latest"]
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### Option 2: From GitHub
|
|
49
|
-
|
|
50
|
-
```json
|
|
51
|
-
{
|
|
52
|
-
"$schema": "https://opencode.ai/config.json",
|
|
53
|
-
"plugin": ["github:MasuRii/opencode-smart-voice-notify"]
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
### Option 3: Local Development
|
|
58
|
-
|
|
59
|
-
1. Clone the repository:
|
|
60
|
-
```bash
|
|
61
|
-
git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
2. Reference the local path in your config:
|
|
65
|
-
```json
|
|
66
|
-
{
|
|
67
|
-
"plugin": ["file:///path/to/opencode-smart-voice-notify"]
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## Configuration
|
|
72
|
-
|
|
73
|
-
### Automatic Setup
|
|
74
|
-
|
|
75
|
-
When you first run OpenCode with this plugin installed, it will **automatically create**:
|
|
76
|
-
|
|
77
|
-
1. **`~/.config/opencode/smart-voice-notify.jsonc`** - A comprehensive configuration file with all available options fully documented.
|
|
78
|
-
2. **`~/.config/opencode/assets/*.mp3`** - Bundled notification sound files.
|
|
79
|
-
|
|
80
|
-
The auto-generated configuration includes all advanced settings, message arrays, and engine options, so you don't have to refer back to the documentation for available settings.
|
|
81
|
-
|
|
82
|
-
### Manual Configuration
|
|
83
|
-
|
|
84
|
-
If you prefer to create the config manually, add a `smart-voice-notify.jsonc` file in your OpenCode config directory (`~/.config/opencode/`):
|
|
85
|
-
|
|
86
|
-
```jsonc
|
|
87
|
-
{
|
|
88
|
-
// ============================================================
|
|
89
|
-
// NOTIFICATION MODE SETTINGS (Smart Notification System)
|
|
90
|
-
// ============================================================
|
|
91
|
-
// Controls how notifications are delivered:
|
|
92
|
-
// 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
|
|
93
|
-
// 'tts-first' - Speak TTS immediately, no sound
|
|
94
|
-
// 'both' - Play sound AND speak TTS immediately
|
|
95
|
-
// 'sound-only' - Only play sound, no TTS at all
|
|
96
|
-
"notificationMode": "sound-first",
|
|
97
|
-
|
|
98
|
-
// ============================================================
|
|
99
|
-
// TTS REMINDER SETTINGS (When user doesn't respond to sound)
|
|
100
|
-
// ============================================================
|
|
101
|
-
|
|
102
|
-
// Enable TTS reminder if user doesn't respond after sound notification
|
|
103
|
-
"enableTTSReminder": true,
|
|
104
|
-
|
|
105
|
-
// Delay (in seconds) before TTS reminder fires
|
|
106
|
-
"ttsReminderDelaySeconds": 30, // Global default
|
|
107
|
-
"idleReminderDelaySeconds": 30, // For task completion notifications
|
|
108
|
-
"permissionReminderDelaySeconds": 20, // For permission requests (more urgent)
|
|
109
|
-
|
|
110
|
-
// Follow-up reminders if user STILL doesn't respond after first TTS
|
|
111
|
-
"enableFollowUpReminders": true,
|
|
112
|
-
"maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
|
|
113
|
-
"reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
|
|
114
|
-
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
### Option 1: From npm (Recommended)
|
|
46
|
+
|
|
47
|
+
Add to your OpenCode config file (`~/.config/opencode/opencode.json`):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"$schema": "https://opencode.ai/config.json",
|
|
52
|
+
"plugin": ["opencode-smart-voice-notify@latest"]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Option 2: From GitHub
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"$schema": "https://opencode.ai/config.json",
|
|
61
|
+
"plugin": ["github:MasuRii/opencode-smart-voice-notify"]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Option 3: Local Development
|
|
66
|
+
|
|
67
|
+
1. Clone the repository:
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. Reference the local path in your config:
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"plugin": ["file:///path/to/opencode-smart-voice-notify"]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
### Automatic Setup
|
|
82
|
+
|
|
83
|
+
When you first run OpenCode with this plugin installed, it will **automatically create**:
|
|
84
|
+
|
|
85
|
+
1. **`~/.config/opencode/smart-voice-notify.jsonc`** - A comprehensive configuration file with all available options fully documented.
|
|
86
|
+
2. **`~/.config/opencode/assets/*.mp3`** - Bundled notification sound files.
|
|
87
|
+
|
|
88
|
+
The auto-generated configuration includes all advanced settings, message arrays, and engine options, so you don't have to refer back to the documentation for available settings.
|
|
89
|
+
|
|
90
|
+
### Manual Configuration
|
|
91
|
+
|
|
92
|
+
If you prefer to create the config manually, add a `smart-voice-notify.jsonc` file in your OpenCode config directory (`~/.config/opencode/`):
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
{
|
|
96
|
+
// ============================================================
|
|
97
|
+
// NOTIFICATION MODE SETTINGS (Smart Notification System)
|
|
98
|
+
// ============================================================
|
|
99
|
+
// Controls how notifications are delivered:
|
|
100
|
+
// 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
|
|
101
|
+
// 'tts-first' - Speak TTS immediately, no sound
|
|
102
|
+
// 'both' - Play sound AND speak TTS immediately
|
|
103
|
+
// 'sound-only' - Only play sound, no TTS at all
|
|
104
|
+
"notificationMode": "sound-first",
|
|
105
|
+
|
|
106
|
+
// ============================================================
|
|
107
|
+
// TTS REMINDER SETTINGS (When user doesn't respond to sound)
|
|
108
|
+
// ============================================================
|
|
109
|
+
|
|
110
|
+
// Enable TTS reminder if user doesn't respond after sound notification
|
|
111
|
+
"enableTTSReminder": true,
|
|
112
|
+
|
|
113
|
+
// Delay (in seconds) before TTS reminder fires
|
|
114
|
+
"ttsReminderDelaySeconds": 30, // Global default
|
|
115
|
+
"idleReminderDelaySeconds": 30, // For task completion notifications
|
|
116
|
+
"permissionReminderDelaySeconds": 20, // For permission requests (more urgent)
|
|
117
|
+
|
|
118
|
+
// Follow-up reminders if user STILL doesn't respond after first TTS
|
|
119
|
+
"enableFollowUpReminders": true,
|
|
120
|
+
"maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
|
|
121
|
+
"reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
|
|
122
|
+
|
|
115
123
|
// ============================================================
|
|
116
124
|
// TTS ENGINE SELECTION
|
|
117
125
|
// ============================================================
|
|
118
126
|
// 'elevenlabs' - Best quality, anime-like voices (requires API key)
|
|
119
|
-
// 'edge' - Good quality neural voices (
|
|
127
|
+
// 'edge' - Good quality neural voices (Free, Native Node.js implementation)
|
|
120
128
|
// 'sapi' - Windows built-in voices (free, offline)
|
|
121
129
|
"ttsEngine": "edge",
|
|
122
130
|
"enableTTS": true,
|
|
123
|
-
|
|
124
|
-
// ============================================================
|
|
125
|
-
// ELEVENLABS SETTINGS (Best Quality - Anime-like Voices)
|
|
126
|
-
// ============================================================
|
|
127
|
-
// Get your API key from: https://elevenlabs.io/app/settings/api-keys
|
|
128
|
-
// "elevenLabsApiKey": "YOUR_API_KEY_HERE",
|
|
129
|
-
"elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
|
|
130
|
-
"elevenLabsModel": "eleven_turbo_v2_5",
|
|
131
|
-
"elevenLabsStability": 0.5,
|
|
132
|
-
"elevenLabsSimilarity": 0.75,
|
|
133
|
-
"elevenLabsStyle": 0.5,
|
|
134
|
-
|
|
135
|
-
// ============================================================
|
|
136
|
-
// EDGE TTS SETTINGS (Free Neural Voices - Default Engine)
|
|
137
|
-
// ============================================================
|
|
138
|
-
"edgeVoice": "en-US-AnaNeural",
|
|
139
|
-
"edgePitch": "+50Hz",
|
|
140
|
-
"edgeRate": "+10%",
|
|
141
|
-
|
|
142
|
-
// ============================================================
|
|
143
|
-
// SAPI SETTINGS (Windows Built-in - Last Resort Fallback)
|
|
144
|
-
// ============================================================
|
|
145
|
-
"sapiVoice": "Microsoft Zira Desktop",
|
|
146
|
-
"sapiRate": -1,
|
|
147
|
-
"sapiPitch": "medium",
|
|
148
|
-
"sapiVolume": "loud",
|
|
149
|
-
|
|
150
|
-
// ============================================================
|
|
151
|
-
// INITIAL TTS MESSAGES (Used immediately or after sound)
|
|
152
|
-
// ============================================================
|
|
153
|
-
"idleTTSMessages": [
|
|
154
|
-
"All done! Your task has been completed successfully.",
|
|
155
|
-
"Hey there! I finished working on your request.",
|
|
156
|
-
"Task complete! Ready for your review whenever you are.",
|
|
157
|
-
"Good news! Everything is done and ready for you.",
|
|
158
|
-
"Finished! Let me know if you need anything else."
|
|
159
|
-
],
|
|
160
|
-
"permissionTTSMessages": [
|
|
161
|
-
"Attention please! I need your permission to continue.",
|
|
162
|
-
"Hey! Quick approval needed to proceed with the task.",
|
|
163
|
-
"Heads up! There is a permission request waiting for you.",
|
|
164
|
-
"Excuse me! I need your authorization before I can continue.",
|
|
165
|
-
"Permission required! Please review and approve when ready."
|
|
166
|
-
],
|
|
167
|
-
|
|
168
|
-
// ============================================================
|
|
169
|
-
// TTS REMINDER MESSAGES (Used after delay if no response)
|
|
170
|
-
// ============================================================
|
|
171
|
-
"idleReminderTTSMessages": [
|
|
172
|
-
"Hey, are you still there? Your task has been waiting for review.",
|
|
173
|
-
"Just a gentle reminder - I finished your request a while ago!",
|
|
174
|
-
"Hello? I completed your task. Please take a look when you can.",
|
|
175
|
-
"Still waiting for you! The work is done and ready for review.",
|
|
176
|
-
"Knock knock! Your completed task is patiently waiting for you."
|
|
177
|
-
],
|
|
178
|
-
"permissionReminderTTSMessages": [
|
|
179
|
-
"Hey! I still need your permission to continue. Please respond!",
|
|
180
|
-
"Reminder: There is a pending permission request. I cannot proceed without you.",
|
|
181
|
-
"Hello? I am waiting for your approval. This is getting urgent!",
|
|
182
|
-
"Please check your screen! I really need your permission to move forward.",
|
|
183
|
-
"Still waiting for authorization! The task is on hold until you respond."
|
|
184
|
-
],
|
|
185
|
-
|
|
186
|
-
// ============================================================
|
|
187
|
-
// SOUND FILES (relative to OpenCode config directory)
|
|
188
|
-
// ============================================================
|
|
189
|
-
"idleSound": "assets/Soft-high-tech-notification-sound-effect.mp3",
|
|
190
|
-
"permissionSound": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
191
|
-
|
|
192
|
-
// ============================================================
|
|
193
|
-
// GENERAL SETTINGS
|
|
194
|
-
// ============================================================
|
|
195
|
-
"wakeMonitor": true,
|
|
196
|
-
"forceVolume": true,
|
|
197
|
-
"volumeThreshold": 50,
|
|
198
|
-
"enableToast": true,
|
|
199
|
-
"enableSound": true,
|
|
200
|
-
"idleThresholdSeconds": 60,
|
|
201
|
-
"debugLog": false
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
See `example.config.jsonc` for more details.
|
|
206
|
-
|
|
131
|
+
|
|
132
|
+
// ============================================================
|
|
133
|
+
// ELEVENLABS SETTINGS (Best Quality - Anime-like Voices)
|
|
134
|
+
// ============================================================
|
|
135
|
+
// Get your API key from: https://elevenlabs.io/app/settings/api-keys
|
|
136
|
+
// "elevenLabsApiKey": "YOUR_API_KEY_HERE",
|
|
137
|
+
"elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
|
|
138
|
+
"elevenLabsModel": "eleven_turbo_v2_5",
|
|
139
|
+
"elevenLabsStability": 0.5,
|
|
140
|
+
"elevenLabsSimilarity": 0.75,
|
|
141
|
+
"elevenLabsStyle": 0.5,
|
|
142
|
+
|
|
143
|
+
// ============================================================
|
|
144
|
+
// EDGE TTS SETTINGS (Free Neural Voices - Default Engine)
|
|
145
|
+
// ============================================================
|
|
146
|
+
"edgeVoice": "en-US-AnaNeural",
|
|
147
|
+
"edgePitch": "+50Hz",
|
|
148
|
+
"edgeRate": "+10%",
|
|
149
|
+
|
|
150
|
+
// ============================================================
|
|
151
|
+
// SAPI SETTINGS (Windows Built-in - Last Resort Fallback)
|
|
152
|
+
// ============================================================
|
|
153
|
+
"sapiVoice": "Microsoft Zira Desktop",
|
|
154
|
+
"sapiRate": -1,
|
|
155
|
+
"sapiPitch": "medium",
|
|
156
|
+
"sapiVolume": "loud",
|
|
157
|
+
|
|
158
|
+
// ============================================================
|
|
159
|
+
// INITIAL TTS MESSAGES (Used immediately or after sound)
|
|
160
|
+
// ============================================================
|
|
161
|
+
"idleTTSMessages": [
|
|
162
|
+
"All done! Your task has been completed successfully.",
|
|
163
|
+
"Hey there! I finished working on your request.",
|
|
164
|
+
"Task complete! Ready for your review whenever you are.",
|
|
165
|
+
"Good news! Everything is done and ready for you.",
|
|
166
|
+
"Finished! Let me know if you need anything else."
|
|
167
|
+
],
|
|
168
|
+
"permissionTTSMessages": [
|
|
169
|
+
"Attention please! I need your permission to continue.",
|
|
170
|
+
"Hey! Quick approval needed to proceed with the task.",
|
|
171
|
+
"Heads up! There is a permission request waiting for you.",
|
|
172
|
+
"Excuse me! I need your authorization before I can continue.",
|
|
173
|
+
"Permission required! Please review and approve when ready."
|
|
174
|
+
],
|
|
175
|
+
|
|
176
|
+
// ============================================================
|
|
177
|
+
// TTS REMINDER MESSAGES (Used after delay if no response)
|
|
178
|
+
// ============================================================
|
|
179
|
+
"idleReminderTTSMessages": [
|
|
180
|
+
"Hey, are you still there? Your task has been waiting for review.",
|
|
181
|
+
"Just a gentle reminder - I finished your request a while ago!",
|
|
182
|
+
"Hello? I completed your task. Please take a look when you can.",
|
|
183
|
+
"Still waiting for you! The work is done and ready for review.",
|
|
184
|
+
"Knock knock! Your completed task is patiently waiting for you."
|
|
185
|
+
],
|
|
186
|
+
"permissionReminderTTSMessages": [
|
|
187
|
+
"Hey! I still need your permission to continue. Please respond!",
|
|
188
|
+
"Reminder: There is a pending permission request. I cannot proceed without you.",
|
|
189
|
+
"Hello? I am waiting for your approval. This is getting urgent!",
|
|
190
|
+
"Please check your screen! I really need your permission to move forward.",
|
|
191
|
+
"Still waiting for authorization! The task is on hold until you respond."
|
|
192
|
+
],
|
|
193
|
+
|
|
194
|
+
// ============================================================
|
|
195
|
+
// SOUND FILES (relative to OpenCode config directory)
|
|
196
|
+
// ============================================================
|
|
197
|
+
"idleSound": "assets/Soft-high-tech-notification-sound-effect.mp3",
|
|
198
|
+
"permissionSound": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
199
|
+
|
|
200
|
+
// ============================================================
|
|
201
|
+
// GENERAL SETTINGS
|
|
202
|
+
// ============================================================
|
|
203
|
+
"wakeMonitor": true,
|
|
204
|
+
"forceVolume": true,
|
|
205
|
+
"volumeThreshold": 50,
|
|
206
|
+
"enableToast": true,
|
|
207
|
+
"enableSound": true,
|
|
208
|
+
"idleThresholdSeconds": 60,
|
|
209
|
+
"debugLog": false
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
See `example.config.jsonc` for more details.
|
|
214
|
+
|
|
207
215
|
## Requirements
|
|
208
216
|
|
|
209
217
|
### For ElevenLabs TTS
|
|
@@ -211,64 +219,64 @@ See `example.config.jsonc` for more details.
|
|
|
211
219
|
- Internet connection
|
|
212
220
|
|
|
213
221
|
### For Edge TTS
|
|
214
|
-
-
|
|
215
|
-
```bash
|
|
216
|
-
pip install edge-tts
|
|
217
|
-
```
|
|
222
|
+
- Internet connection (No external dependencies required)
|
|
218
223
|
|
|
219
224
|
### For Windows SAPI
|
|
220
225
|
- Windows OS (uses built-in System.Speech)
|
|
221
226
|
|
|
222
227
|
### For Sound Playback
|
|
223
|
-
- **Windows**: Built-in (uses Windows Media Player)
|
|
224
|
-
- **macOS**: Built-in (`afplay`)
|
|
225
|
-
- **Linux**: `paplay` or `aplay`
|
|
226
|
-
|
|
227
|
-
## Events Handled
|
|
228
|
-
|
|
229
|
-
| Event | Action |
|
|
230
|
-
|-------|--------|
|
|
231
|
-
| `session.idle` | Agent finished working - notify user |
|
|
232
|
-
| `permission.updated` | Permission request - alert user |
|
|
233
|
-
| `permission.replied` | User responded - cancel pending reminders |
|
|
234
|
-
| `message.updated` | New user message - cancel pending reminders |
|
|
235
|
-
| `session.created` | New session - reset state |
|
|
236
|
-
|
|
237
|
-
## Development
|
|
238
|
-
|
|
239
|
-
To develop on this plugin locally:
|
|
240
|
-
|
|
241
|
-
1. Clone the repository:
|
|
242
|
-
```bash
|
|
243
|
-
git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
|
|
244
|
-
cd opencode-smart-voice-notify
|
|
245
|
-
bun install # or npm install
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
2. Link to your OpenCode config:
|
|
249
|
-
```json
|
|
250
|
-
{
|
|
251
|
-
"plugin": ["file:///absolute/path/to/opencode-smart-voice-notify"]
|
|
252
|
-
}
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
## Updating
|
|
256
|
-
|
|
257
|
-
OpenCode does not automatically update plugins. To update to the latest version:
|
|
258
|
-
|
|
259
|
-
```bash
|
|
260
|
-
# Clear the cached plugin
|
|
261
|
-
rm -rf ~/.cache/opencode/node_modules/opencode-smart-voice-notify
|
|
262
|
-
|
|
263
|
-
# Run OpenCode to trigger a fresh install
|
|
264
|
-
opencode
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
## License
|
|
268
|
-
|
|
269
|
-
MIT
|
|
270
|
-
|
|
271
|
-
## Support
|
|
272
|
-
|
|
273
|
-
- Open an issue on [GitHub](https://github.com/MasuRii/opencode-smart-voice-notify/issues)
|
|
274
|
-
- Check the [OpenCode docs](https://opencode.ai/docs/plugins)
|
|
228
|
+
- **Windows**: Built-in (uses Windows Media Player)
|
|
229
|
+
- **macOS**: Built-in (`afplay`)
|
|
230
|
+
- **Linux**: `paplay` or `aplay`
|
|
231
|
+
|
|
232
|
+
## Events Handled
|
|
233
|
+
|
|
234
|
+
| Event | Action |
|
|
235
|
+
|-------|--------|
|
|
236
|
+
| `session.idle` | Agent finished working - notify user |
|
|
237
|
+
| `permission.updated` | Permission request - alert user |
|
|
238
|
+
| `permission.replied` | User responded - cancel pending reminders |
|
|
239
|
+
| `message.updated` | New user message - cancel pending reminders |
|
|
240
|
+
| `session.created` | New session - reset state |
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
To develop on this plugin locally:
|
|
245
|
+
|
|
246
|
+
1. Clone the repository:
|
|
247
|
+
```bash
|
|
248
|
+
git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
|
|
249
|
+
cd opencode-smart-voice-notify
|
|
250
|
+
bun install # or npm install
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
2. Link to your OpenCode config:
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"plugin": ["file:///absolute/path/to/opencode-smart-voice-notify"]
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Updating
|
|
261
|
+
|
|
262
|
+
OpenCode does not automatically update plugins. To update to the latest version:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
# Clear the cached plugin
|
|
266
|
+
rm -rf ~/.cache/opencode/node_modules/opencode-smart-voice-notify
|
|
267
|
+
|
|
268
|
+
# Run OpenCode to trigger a fresh install
|
|
269
|
+
opencode
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT
|
|
275
|
+
|
|
276
|
+
## Support
|
|
277
|
+
|
|
278
|
+
- Open an issue on [GitHub](https://github.com/MasuRii/opencode-smart-voice-notify/issues)
|
|
279
|
+
- Check the [OpenCode docs](https://opencode.ai/docs/plugins)
|
|
280
|
+
|
|
281
|
+
<!-- Dynamic Header -->
|
|
282
|
+
<img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=0:667eea,100:764ba2&height=120§ion=header"/>
|
package/example.config.jsonc
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
// TTS ENGINE SELECTION
|
|
49
49
|
// ============================================================
|
|
50
50
|
// 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
|
|
51
|
-
// 'edge' - Good quality neural voices (
|
|
51
|
+
// 'edge' - Good quality neural voices (Free, Native Node.js implementation)
|
|
52
52
|
// 'sapi' - Windows built-in voices (free, offline, robotic)
|
|
53
53
|
"ttsEngine": "elevenlabs",
|
|
54
54
|
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
// ============================================================
|
|
82
82
|
// EDGE TTS SETTINGS (Free Neural Voices - Fallback)
|
|
83
83
|
// ============================================================
|
|
84
|
-
//
|
|
84
|
+
// Native Node.js implementation (No external dependencies)
|
|
85
85
|
|
|
86
86
|
// Voice options (run 'edge-tts --list-voices' to see all):
|
|
87
87
|
// 'en-US-AnaNeural' - Young, cute, cartoon-like (RECOMMENDED)
|
package/index.js
CHANGED
|
@@ -198,6 +198,12 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
|
|
|
198
198
|
fallbackSound: options.fallbackSound
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
// CRITICAL FIX: Check if cancelled during playback (user responded while TTS was speaking)
|
|
202
|
+
if (!pendingReminders.has(type)) {
|
|
203
|
+
debugLog(`scheduleTTSReminder: ${type} cancelled during playback - aborting follow-up`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
201
207
|
// Clean up
|
|
202
208
|
pendingReminders.delete(type);
|
|
203
209
|
|
|
@@ -270,6 +276,18 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
|
|
|
270
276
|
await playSound(soundFile, soundLoops);
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
// CRITICAL FIX: Check if user responded during sound playback
|
|
280
|
+
// For idle notifications: check if there was new activity after the idle start
|
|
281
|
+
if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
|
|
282
|
+
debugLog(`smartNotify: user active during sound - aborting idle reminder`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// For permission notifications: check if the permission was already handled
|
|
286
|
+
if (type === 'permission' && !activePermissionId) {
|
|
287
|
+
debugLog(`smartNotify: permission handled during sound - aborting reminder`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
273
291
|
// Step 2: Schedule TTS reminder if user doesn't respond
|
|
274
292
|
if (config.enableTTSReminder && ttsMessage) {
|
|
275
293
|
scheduleTTSReminder(type, ttsMessage, { fallbackSound });
|
|
@@ -347,9 +365,12 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
|
|
|
347
365
|
// CRITICAL: Clear activePermissionId FIRST to prevent race condition
|
|
348
366
|
// where permission.updated handler is still running async operations
|
|
349
367
|
const repliedPermissionId = event.properties?.permissionID;
|
|
350
|
-
|
|
368
|
+
|
|
369
|
+
// Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
|
|
370
|
+
// (This happens if permission.updated received an event without permissionID)
|
|
371
|
+
if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
|
|
351
372
|
activePermissionId = null;
|
|
352
|
-
debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId}`);
|
|
373
|
+
debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
|
|
353
374
|
}
|
|
354
375
|
lastUserActivityTime = Date.now();
|
|
355
376
|
cancelPendingReminder('permission'); // Cancel permission-specific reminder
|
|
@@ -402,7 +423,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
|
|
|
402
423
|
if (event.type === "permission.updated") {
|
|
403
424
|
// CRITICAL: Capture permissionID IMMEDIATELY (before any async work)
|
|
404
425
|
// This prevents race condition where user responds before we finish notifying
|
|
405
|
-
|
|
426
|
+
// NOTE: In permission.updated, the property is 'id', but in permission.replied it is 'permissionID'
|
|
427
|
+
const permissionId = event.properties?.id;
|
|
428
|
+
|
|
429
|
+
if (!permissionId) {
|
|
430
|
+
debugLog('permission.updated: permission ID missing. properties keys: ' + Object.keys(event.properties || {}).join(', '));
|
|
431
|
+
}
|
|
432
|
+
|
|
406
433
|
activePermissionId = permissionId;
|
|
407
434
|
|
|
408
435
|
debugLog(`permission.updated: notifying (permissionId=${permissionId})`);
|
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",
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
"node": ">=18.0.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@elevenlabs/elevenlabs-js": "^2.28.0"
|
|
41
|
+
"@elevenlabs/elevenlabs-js": "^2.28.0",
|
|
42
|
+
"msedge-tts": "^2.0.3"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"@opencode-ai/plugin": "^1.0.0"
|
|
45
46
|
}
|
|
46
|
-
}
|
|
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');
|
|
@@ -110,6 +111,8 @@ export const getTTSConfig = () => {
|
|
|
110
111
|
});
|
|
111
112
|
};
|
|
112
113
|
|
|
114
|
+
let elevenLabsQuotaExceeded = false;
|
|
115
|
+
|
|
113
116
|
/**
|
|
114
117
|
* Creates a TTS utility instance
|
|
115
118
|
* @param {object} params - { $, client }
|
|
@@ -119,6 +122,7 @@ export const createTTS = ({ $, client }) => {
|
|
|
119
122
|
const config = getTTSConfig();
|
|
120
123
|
const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
|
|
121
124
|
|
|
125
|
+
// Debug logging function (defined early so it can be passed to Linux platform)
|
|
122
126
|
const debugLog = (message) => {
|
|
123
127
|
if (!config.debugLog) return;
|
|
124
128
|
try {
|
|
@@ -127,6 +131,24 @@ export const createTTS = ({ $, client }) => {
|
|
|
127
131
|
} catch (e) {}
|
|
128
132
|
};
|
|
129
133
|
|
|
134
|
+
// Initialize Linux platform utilities (only used on Linux)
|
|
135
|
+
const linux = platform === 'linux' ? createLinuxPlatform({ $, debugLog }) : null;
|
|
136
|
+
|
|
137
|
+
const showToast = async (message, variant = 'info') => {
|
|
138
|
+
if (!config.enableToast) return;
|
|
139
|
+
try {
|
|
140
|
+
if (typeof client?.tui?.showToast === 'function') {
|
|
141
|
+
await client.tui.showToast({
|
|
142
|
+
body: {
|
|
143
|
+
message: message,
|
|
144
|
+
variant: variant,
|
|
145
|
+
duration: 6000
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
};
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
153
|
* Play an audio file using system media player
|
|
132
154
|
*/
|
|
@@ -156,7 +178,11 @@ export const createTTS = ({ $, client }) => {
|
|
|
156
178
|
for (let i = 0; i < loops; i++) {
|
|
157
179
|
await $`afplay ${filePath}`.quiet();
|
|
158
180
|
}
|
|
181
|
+
} else if (platform === 'linux' && linux) {
|
|
182
|
+
// Use the Linux platform module for audio playback
|
|
183
|
+
await linux.playAudioFile(filePath, loops);
|
|
159
184
|
} else {
|
|
185
|
+
// Generic fallback for other Unix-like systems
|
|
160
186
|
for (let i = 0; i < loops; i++) {
|
|
161
187
|
try {
|
|
162
188
|
await $`paplay ${filePath}`.quiet();
|
|
@@ -174,6 +200,8 @@ export const createTTS = ({ $, client }) => {
|
|
|
174
200
|
* ElevenLabs Engine (Online, High Quality, Anime-like voices)
|
|
175
201
|
*/
|
|
176
202
|
const speakWithElevenLabs = async (text) => {
|
|
203
|
+
if (elevenLabsQuotaExceeded) return false;
|
|
204
|
+
|
|
177
205
|
if (!config.elevenLabsApiKey) {
|
|
178
206
|
debugLog('speakWithElevenLabs: No API key configured');
|
|
179
207
|
return false;
|
|
@@ -204,6 +232,19 @@ export const createTTS = ({ $, client }) => {
|
|
|
204
232
|
return true;
|
|
205
233
|
} catch (e) {
|
|
206
234
|
debugLog(`speakWithElevenLabs error: ${e.message}`);
|
|
235
|
+
|
|
236
|
+
// Handle quota exceeded (401 specifically, or specific error message)
|
|
237
|
+
const isQuotaError =
|
|
238
|
+
e.statusCode === 401 ||
|
|
239
|
+
e.message?.includes('401') ||
|
|
240
|
+
e.message?.toLowerCase().includes('quota_exceeded') ||
|
|
241
|
+
e.message?.toLowerCase().includes('quota exceeded');
|
|
242
|
+
|
|
243
|
+
if (isQuotaError) {
|
|
244
|
+
elevenLabsQuotaExceeded = true;
|
|
245
|
+
await showToast("⚠️ ElevenLabs quota exceeded! Switching to Edge TTS for this session.", "error");
|
|
246
|
+
}
|
|
247
|
+
|
|
207
248
|
return false;
|
|
208
249
|
}
|
|
209
250
|
};
|
|
@@ -212,16 +253,20 @@ export const createTTS = ({ $, client }) => {
|
|
|
212
253
|
* Edge TTS Engine (Free, Neural voices)
|
|
213
254
|
*/
|
|
214
255
|
const speakWithEdgeTTS = async (text) => {
|
|
215
|
-
if (!$) return false;
|
|
216
256
|
try {
|
|
217
|
-
const
|
|
257
|
+
const { MsEdgeTTS, OUTPUT_FORMAT } = await import('msedge-tts');
|
|
258
|
+
const tts = new MsEdgeTTS();
|
|
259
|
+
const voice = config.edgeVoice || 'en-US-JennyNeural';
|
|
218
260
|
const pitch = config.edgePitch || '+0Hz';
|
|
219
|
-
const rate = config.edgeRate || '+
|
|
220
|
-
const
|
|
261
|
+
const rate = config.edgeRate || '+10%';
|
|
262
|
+
const volume = config.edgeVolume || '+0%';
|
|
221
263
|
|
|
222
|
-
await
|
|
223
|
-
|
|
224
|
-
|
|
264
|
+
await tts.setMetadata(voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
|
|
265
|
+
|
|
266
|
+
const { audioFilePath } = await tts.toFile(os.tmpdir(), text, { pitch, rate, volume });
|
|
267
|
+
|
|
268
|
+
await playAudioFile(audioFilePath);
|
|
269
|
+
try { fs.unlinkSync(audioFilePath); } catch (e) {}
|
|
225
270
|
return true;
|
|
226
271
|
} catch (e) {
|
|
227
272
|
debugLog(`speakWithEdgeTTS error: ${e.message}`);
|
|
@@ -301,8 +346,15 @@ ${ssml}
|
|
|
301
346
|
|
|
302
347
|
/**
|
|
303
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.
|
|
304
351
|
*/
|
|
305
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
|
+
}
|
|
306
358
|
if (platform !== 'win32' || !$) return true;
|
|
307
359
|
try {
|
|
308
360
|
const idleThreshold = config.idleThresholdSeconds || 60;
|
|
@@ -342,6 +394,10 @@ public static class IdleCheck {
|
|
|
342
394
|
* Get the current system volume level (0-100).
|
|
343
395
|
*/
|
|
344
396
|
const getCurrentVolume = async () => {
|
|
397
|
+
// Use Linux platform module
|
|
398
|
+
if (platform === 'linux' && linux) {
|
|
399
|
+
return await linux.getCurrentVolume();
|
|
400
|
+
}
|
|
345
401
|
if (platform !== 'win32' || !$) return -1;
|
|
346
402
|
try {
|
|
347
403
|
const cmd = `
|
|
@@ -380,6 +436,9 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
|
380
436
|
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
381
437
|
} else if (platform === 'darwin') {
|
|
382
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();
|
|
383
442
|
}
|
|
384
443
|
} catch (e) {
|
|
385
444
|
debugLog(`wakeMonitor error: ${e.message}`);
|
|
@@ -403,6 +462,9 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
|
403
462
|
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
404
463
|
} else if (platform === 'darwin') {
|
|
405
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();
|
|
406
468
|
}
|
|
407
469
|
} catch (e) {
|
|
408
470
|
debugLog(`forceVolume error: ${e.message}`);
|