pi-smart-voice-notify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/logging.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ export function getErrorMessage(error: unknown): string {
4
+ if (error instanceof Error) {
5
+ return error.message;
6
+ }
7
+ return String(error);
8
+ }
9
+
10
+ function safeJsonStringify(value: unknown): string {
11
+ const seen = new WeakSet<object>();
12
+ return JSON.stringify(value, (_key, currentValue) => {
13
+ if (currentValue instanceof Error) {
14
+ return {
15
+ name: currentValue.name,
16
+ message: currentValue.message,
17
+ stack: currentValue.stack,
18
+ };
19
+ }
20
+ if (typeof currentValue === "bigint") {
21
+ return currentValue.toString();
22
+ }
23
+ if (typeof currentValue === "object" && currentValue !== null) {
24
+ if (seen.has(currentValue)) {
25
+ return "[Circular]";
26
+ }
27
+ seen.add(currentValue);
28
+ }
29
+ return currentValue;
30
+ });
31
+ }
32
+
33
+ interface LoggerOptions {
34
+ extensionId: string;
35
+ debugLogPath: string;
36
+ isDebugEnabled: () => boolean;
37
+ ensureDebugDirectory: () => void;
38
+ }
39
+
40
+ export interface ExtensionLogger {
41
+ debug: (event: string, details?: Record<string, unknown>) => void;
42
+ error: (error: unknown) => void;
43
+ }
44
+
45
+ export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
46
+ const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
47
+
48
+ const debug = (event: string, details: Record<string, unknown> = {}): void => {
49
+ if (!isDebugEnabled()) {
50
+ return;
51
+ }
52
+
53
+ try {
54
+ ensureDebugDirectory();
55
+ const line = safeJsonStringify({
56
+ timestamp: new Date().toISOString(),
57
+ extension: extensionId,
58
+ event,
59
+ ...details,
60
+ });
61
+ appendFileSync(debugLogPath, `${line}\n`, "utf-8");
62
+ } catch (error) {
63
+ console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
64
+ }
65
+ };
66
+
67
+ const error = (cause: unknown): void => {
68
+ debug("runtime.error", { error: cause });
69
+ console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
70
+ };
71
+
72
+ return { debug, error };
73
+ }
@@ -0,0 +1,414 @@
1
+ import {
2
+ clampInt,
3
+ DEFAULT_CONFIG,
4
+ isWindows,
5
+ resolveSoundFile,
6
+ SOUND_LOOPS,
7
+ } from "./config-store.js";
8
+ import type { NotificationType, VoiceNotifyConfig } from "./types.js";
9
+
10
+ interface ExecResult {
11
+ code: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ }
15
+
16
+ interface ExecRunner {
17
+ exec: (executable: string, args: string[], options?: { timeout?: number }) => Promise<ExecResult>;
18
+ }
19
+
20
+ interface AudioServiceOptions {
21
+ execRunner: ExecRunner;
22
+ getConfig: () => VoiceNotifyConfig;
23
+ debug: (event: string, details?: Record<string, unknown>) => void;
24
+ }
25
+
26
+ export class AudioNotificationService {
27
+ private readonly execRunner: ExecRunner;
28
+ private readonly getConfig: () => VoiceNotifyConfig;
29
+ private readonly debug: (event: string, details?: Record<string, unknown>) => void;
30
+ private voicesCache: { values: string[]; timestamp: number } = { values: [], timestamp: 0 };
31
+
32
+ public constructor(options: AudioServiceOptions) {
33
+ this.execRunner = options.execRunner;
34
+ this.getConfig = options.getConfig;
35
+ this.debug = options.debug;
36
+ }
37
+
38
+ public async wakeMonitor(): Promise<void> {
39
+ const config = this.getConfig();
40
+ if (!config.wakeMonitor) {
41
+ this.debug("wake.monitor.skipped", { reason: "disabled" });
42
+ return;
43
+ }
44
+
45
+ const threshold = clampInt(config.idleThresholdSeconds, DEFAULT_CONFIG.idleThresholdSeconds, 5, 600);
46
+
47
+ try {
48
+ const idleSeconds = await this.getSystemIdleSeconds();
49
+ if (idleSeconds < threshold) {
50
+ this.debug("wake.monitor.skipped", {
51
+ reason: "below_threshold",
52
+ idleSeconds,
53
+ threshold,
54
+ });
55
+ return;
56
+ }
57
+
58
+ this.debug("wake.monitor.attempt", {
59
+ platform: process.platform,
60
+ idleSeconds,
61
+ threshold,
62
+ });
63
+
64
+ if (isWindows()) {
65
+ const script = "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{F15}')";
66
+ const result = await this.runPowerShell(script, 6_000, "wake-monitor-windows");
67
+ if (!result.ok) {
68
+ throw new Error(result.stderr || result.stdout || "Failed to wake monitor on Windows");
69
+ }
70
+ this.debug("wake.monitor.success", { platform: process.platform });
71
+ return;
72
+ }
73
+
74
+ if (process.platform === "darwin") {
75
+ const result = await this.runProcess("caffeinate", ["-u", "-t", "1"], 6_000, "wake-monitor-macos");
76
+ if (!result.ok) {
77
+ throw new Error(result.stderr || result.stdout || "Failed to wake monitor on macOS");
78
+ }
79
+ this.debug("wake.monitor.success", { platform: process.platform });
80
+ return;
81
+ }
82
+
83
+ if (process.platform === "linux") {
84
+ const script =
85
+ "xset dpms force on || gdbus call --session --dest org.gnome.SettingsDaemon.Power --object-path /org/gnome/SettingsDaemon/Power --method org.gnome.SettingsDaemon.Power.Screen.StepUp";
86
+ const result = await this.runShell(script, 6_000, "wake-monitor-linux");
87
+ if (!result.ok) {
88
+ throw new Error(result.stderr || result.stdout || "Failed to wake monitor on Linux");
89
+ }
90
+ this.debug("wake.monitor.success", { platform: process.platform });
91
+ return;
92
+ }
93
+
94
+ this.debug("wake.monitor.skipped", {
95
+ reason: "unsupported_platform",
96
+ platform: process.platform,
97
+ });
98
+ } catch (error) {
99
+ this.debug("wake.monitor.error", { error });
100
+ }
101
+ }
102
+
103
+ public async playWindowsSound(type: NotificationType): Promise<void> {
104
+ const config = this.getConfig();
105
+ if (!isWindows() || !config.enableSound) {
106
+ return;
107
+ }
108
+
109
+ const soundFile = resolveSoundFile(config, type);
110
+ const loops = SOUND_LOOPS[type];
111
+ const soundFileBase64 = soundFile ? Buffer.from(soundFile, "utf8").toString("base64") : "";
112
+
113
+ const script = `
114
+ $ErrorActionPreference = 'Stop'
115
+ $loops = ${loops}
116
+ $path = ''
117
+ if ('${soundFileBase64}') {
118
+ $path = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${soundFileBase64}'))
119
+ }
120
+
121
+ try {
122
+ if ($path -and (Test-Path -LiteralPath $path)) {
123
+ Add-Type -AssemblyName PresentationCore
124
+ $player = New-Object System.Windows.Media.MediaPlayer
125
+ try {
126
+ $player.Volume = 1.0
127
+ for ($i = 0; $i -lt $loops; $i++) {
128
+ $player.Open([Uri]::new($path))
129
+
130
+ $openDeadline = (Get-Date).AddSeconds(3)
131
+ while (-not $player.NaturalDuration.HasTimeSpan -and (Get-Date) -lt $openDeadline) {
132
+ Start-Sleep -Milliseconds 50
133
+ }
134
+
135
+ $durationMs = if ($player.NaturalDuration.HasTimeSpan) {
136
+ [Math]::Max(200, [Math]::Ceiling($player.NaturalDuration.TimeSpan.TotalMilliseconds))
137
+ } else {
138
+ 2500
139
+ }
140
+
141
+ $player.Position = [TimeSpan]::Zero
142
+ $player.Play()
143
+ Start-Sleep -Milliseconds $durationMs
144
+ $player.Stop()
145
+ Start-Sleep -Milliseconds 120
146
+ }
147
+ } finally {
148
+ $player.Close()
149
+ }
150
+ exit 0
151
+ }
152
+
153
+ for ($i = 0; $i -lt $loops; $i++) {
154
+ try {
155
+ [Console]::Beep(1047, 180)
156
+ Start-Sleep -Milliseconds 80
157
+ [Console]::Beep(1319, 220)
158
+ } catch {
159
+ Start-Sleep -Milliseconds 300
160
+ }
161
+ }
162
+ exit 0
163
+ } catch {
164
+ [Console]::Error.WriteLine($_.Exception.Message)
165
+ exit 1
166
+ }
167
+ `;
168
+
169
+ const result = await this.runPowerShell(script, 30_000, "play-windows-sound");
170
+ if (!result.ok) {
171
+ throw new Error(result.stderr || result.stdout || "Failed to play Windows sound");
172
+ }
173
+ }
174
+
175
+ public async speakWithSapi(text: string): Promise<void> {
176
+ const config = this.getConfig();
177
+ if (!isWindows() || !config.enableTts) {
178
+ return;
179
+ }
180
+
181
+ const textBase64 = Buffer.from(text, "utf8").toString("base64");
182
+ const voiceBase64 = Buffer.from(config.ttsVoice, "utf8").toString("base64");
183
+ const rate = clampInt(config.ttsRate, DEFAULT_CONFIG.ttsRate, -10, 10);
184
+
185
+ const script = `
186
+ Add-Type -AssemblyName System.Speech
187
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
188
+ try {
189
+ $text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${textBase64}'))
190
+ $voice = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${voiceBase64}'))
191
+ $synth.Rate = ${rate}
192
+ if ($voice) {
193
+ try { $synth.SelectVoice($voice) } catch { }
194
+ }
195
+ $synth.Speak($text)
196
+ exit 0
197
+ } catch {
198
+ [Console]::Error.WriteLine($_.Exception.Message)
199
+ exit 1
200
+ } finally {
201
+ if ($synth) { $synth.Dispose() }
202
+ }
203
+ `;
204
+
205
+ const result = await this.runPowerShell(script, 30_000, "speak-sapi");
206
+ if (!result.ok) {
207
+ throw new Error(result.stderr || result.stdout || "Failed to speak with SAPI");
208
+ }
209
+ }
210
+
211
+ public async getInstalledVoices(force = false): Promise<string[]> {
212
+ const config = this.getConfig();
213
+ if (!isWindows()) {
214
+ return [config.ttsVoice];
215
+ }
216
+
217
+ const cacheAge = Date.now() - this.voicesCache.timestamp;
218
+ if (!force && this.voicesCache.values.length > 0 && cacheAge < 5 * 60_000) {
219
+ return this.voicesCache.values;
220
+ }
221
+
222
+ const script = `
223
+ Add-Type -AssemblyName System.Speech
224
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
225
+ try {
226
+ $synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
227
+ } finally {
228
+ if ($synth) { $synth.Dispose() }
229
+ }
230
+ `;
231
+
232
+ const result = await this.runPowerShell(script, 20_000, "list-sapi-voices");
233
+ if (!result.ok) {
234
+ throw new Error(result.stderr || result.stdout || "Failed to list SAPI voices");
235
+ }
236
+
237
+ const values = result.stdout
238
+ .split(/\r?\n/)
239
+ .map((line) => line.trim())
240
+ .filter(Boolean);
241
+ const unique = Array.from(new Set(values));
242
+ if (unique.length === 0) {
243
+ unique.push(config.ttsVoice);
244
+ }
245
+
246
+ if (!unique.includes(config.ttsVoice)) {
247
+ unique.push(config.ttsVoice);
248
+ }
249
+
250
+ this.voicesCache = { values: unique, timestamp: Date.now() };
251
+ return unique;
252
+ }
253
+
254
+ private encodePowerShell(script: string): string {
255
+ return Buffer.from(script, "utf16le").toString("base64");
256
+ }
257
+
258
+ private async runPowerShell(
259
+ script: string,
260
+ timeout = 20_000,
261
+ action = "unknown",
262
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
263
+ const startedAt = Date.now();
264
+ const encoded = this.encodePowerShell(script);
265
+ const result = await this.execRunner.exec(
266
+ "powershell.exe",
267
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
268
+ { timeout },
269
+ );
270
+ const payload = {
271
+ ok: result.code === 0,
272
+ stdout: result.stdout,
273
+ stderr: result.stderr,
274
+ };
275
+ this.debug("powershell.exec", {
276
+ action,
277
+ ok: payload.ok,
278
+ exitCode: result.code,
279
+ durationMs: Date.now() - startedAt,
280
+ stdoutPreview: payload.stdout.slice(0, 300),
281
+ stderrPreview: payload.stderr.slice(0, 300),
282
+ });
283
+ return payload;
284
+ }
285
+
286
+ private async runProcess(
287
+ executable: string,
288
+ args: string[],
289
+ timeout = 20_000,
290
+ action = "process",
291
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
292
+ const startedAt = Date.now();
293
+ const result = await this.execRunner.exec(executable, args, { timeout });
294
+ const payload = {
295
+ ok: result.code === 0,
296
+ stdout: result.stdout,
297
+ stderr: result.stderr,
298
+ };
299
+ this.debug("process.exec", {
300
+ action,
301
+ executable,
302
+ ok: payload.ok,
303
+ exitCode: result.code,
304
+ durationMs: Date.now() - startedAt,
305
+ stdoutPreview: payload.stdout.slice(0, 300),
306
+ stderrPreview: payload.stderr.slice(0, 300),
307
+ });
308
+ return payload;
309
+ }
310
+
311
+ private async runShell(
312
+ script: string,
313
+ timeout = 20_000,
314
+ action = "shell",
315
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
316
+ return this.runProcess("sh", ["-lc", script], timeout, action);
317
+ }
318
+
319
+ private parseIdleSeconds(stdout: string): number | null {
320
+ const candidate = stdout
321
+ .split(/\r?\n/)
322
+ .map((line) => line.trim())
323
+ .find((line) => line.length > 0);
324
+ if (!candidate) {
325
+ return null;
326
+ }
327
+ const value = Number.parseFloat(candidate);
328
+ if (Number.isNaN(value) || !Number.isFinite(value) || value < 0) {
329
+ return null;
330
+ }
331
+ return value;
332
+ }
333
+
334
+ private async getSystemIdleSeconds(): Promise<number> {
335
+ const config = this.getConfig();
336
+ const fallbackSeconds = clampInt(config.idleThresholdSeconds, DEFAULT_CONFIG.idleThresholdSeconds, 5, 600);
337
+
338
+ if (isWindows()) {
339
+ const script = `
340
+ $ErrorActionPreference = 'Stop'
341
+ $signature = @'
342
+ using System;
343
+ using System.Runtime.InteropServices;
344
+ public struct LASTINPUTINFO {
345
+ public uint cbSize;
346
+ public uint dwTime;
347
+ }
348
+ public static class IdleTimeProbe {
349
+ [DllImport("user32.dll")]
350
+ public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
351
+
352
+ [DllImport("kernel32.dll")]
353
+ public static extern ulong GetTickCount64();
354
+ }
355
+ '@
356
+ Add-Type -TypeDefinition $signature -Language CSharp
357
+
358
+ $info = New-Object LASTINPUTINFO
359
+ $info.cbSize = [System.Runtime.InteropServices.Marshal]::SizeOf([type]LASTINPUTINFO)
360
+ if (-not [IdleTimeProbe]::GetLastInputInfo([ref]$info)) {
361
+ throw 'GetLastInputInfo failed.'
362
+ }
363
+ $idleMs = [IdleTimeProbe]::GetTickCount64() - [uint64]$info.dwTime
364
+ [Math]::Floor($idleMs / 1000)
365
+ `;
366
+ const result = await this.runPowerShell(script, 8_000, "idle-seconds-windows");
367
+ const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
368
+ if (parsed !== null) {
369
+ return parsed;
370
+ }
371
+ this.debug("wake.monitor.idle_fallback", {
372
+ platform: process.platform,
373
+ reason: result.ok ? "unparseable" : "exec_failed",
374
+ fallbackSeconds,
375
+ });
376
+ return fallbackSeconds;
377
+ }
378
+
379
+ if (process.platform === "darwin") {
380
+ const result = await this.runShell(
381
+ "ioreg -c IOHIDSystem | awk '/HIDIdleTime/ { printf(\"%d\\n\", $NF/1000000000); exit }'",
382
+ 8_000,
383
+ "idle-seconds-macos",
384
+ );
385
+ const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
386
+ if (parsed !== null) {
387
+ return parsed;
388
+ }
389
+ this.debug("wake.monitor.idle_fallback", {
390
+ platform: process.platform,
391
+ reason: result.ok ? "unparseable" : "exec_failed",
392
+ fallbackSeconds,
393
+ });
394
+ return fallbackSeconds;
395
+ }
396
+
397
+ if (process.platform === "linux") {
398
+ const script = "if command -v xprintidle >/dev/null 2>&1; then xprintidle | awk '{ printf(\"%d\\n\", $1/1000) }'; else echo ''; fi";
399
+ const result = await this.runShell(script, 8_000, "idle-seconds-linux");
400
+ const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
401
+ if (parsed !== null) {
402
+ return parsed;
403
+ }
404
+ this.debug("wake.monitor.idle_fallback", {
405
+ platform: process.platform,
406
+ reason: result.ok ? "unparseable" : "exec_failed",
407
+ fallbackSeconds,
408
+ });
409
+ return fallbackSeconds;
410
+ }
411
+
412
+ return fallbackSeconds;
413
+ }
414
+ }
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type NotificationType = "idle" | "permission" | "question" | "error";
2
+ export type NotificationMode = "sound-first" | "tts-first" | "both" | "sound-only";
3
+ export type NotifyLevel = "info" | "warning" | "error";
4
+
5
+ export interface VoiceNotifyConfig {
6
+ version: 1;
7
+ enabled: boolean;
8
+ windowsOptimized: boolean;
9
+ notificationMode: NotificationMode;
10
+ enableSound: boolean;
11
+ enableTts: boolean;
12
+ enableDesktopNotification: boolean;
13
+ desktopNotificationTimeout: number;
14
+ wakeMonitor: boolean;
15
+ idleThresholdSeconds: number;
16
+ enableIdleNotification: boolean;
17
+ enablePermissionNotification: boolean;
18
+ enableQuestionNotification: boolean;
19
+ enableErrorNotification: boolean;
20
+ reminderEnabled: boolean;
21
+ reminderDelaySeconds: number;
22
+ followUpEnabled: boolean;
23
+ maxFollowUps: number;
24
+ followUpBackoffMultiplier: number;
25
+ minNotificationIntervalMs: number;
26
+ suppressIdleAfterError: boolean;
27
+ ttsVoice: string;
28
+ ttsRate: number;
29
+ idleSoundFile: string;
30
+ permissionSoundFile: string;
31
+ questionSoundFile: string;
32
+ errorSoundFile: string;
33
+ debugLog: boolean;
34
+ }
35
+
36
+ export interface ReminderState {
37
+ timeoutId: NodeJS.Timeout;
38
+ scheduledAt: number;
39
+ followUpCount: number;
40
+ delaySeconds: number;
41
+ }
42
+
43
+ export interface MessageSet {
44
+ initial: string[];
45
+ reminder: string[];
46
+ }
47
+
48
+ export type SoundFileField =
49
+ | "idleSoundFile"
50
+ | "permissionSoundFile"
51
+ | "questionSoundFile"
52
+ | "errorSoundFile";