pi-smart-voice-notify 0.5.4 → 0.6.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/CHANGELOG.md CHANGED
@@ -2,18 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - Added an `enabled` master toggle to the config so the extension can be disabled without uninstalling. ([76cd96f](https://github.com/MasuRii/pi-smart-voice-notify/commit/76cd96fbbd6bd0658f00e8d7c043c44260f856bd))
9
+
10
+ ### Changed
11
+ - Widened Pi coding-agent and Pi TUI peer dependency ranges to include `^0.80.0` and bumped development dependencies (`@types/node` to `26.1.0`, `@earendil-works/pi-coding-agent` to `^0.80.3`). ([146ff6e](https://github.com/MasuRii/pi-smart-voice-notify/commit/146ff6edf87de680958e61f0e1db34e6d337c729))
12
+ - Extracted shared utilities and consolidated duplicated logic across config, notification, and platform modules. ([76cd96f](https://github.com/MasuRii/pi-smart-voice-notify/commit/76cd96fbbd6bd0658f00e8d7c043c44260f856bd))
13
+ - Extracted shared test helpers and consolidated fixtures across the test suite. ([6cf3ed0](https://github.com/MasuRii/pi-smart-voice-notify/commit/6cf3ed0168c1460ffca3c447f4a7a55565f5cade))
14
+
5
15
  ## [0.5.4] - 2026-06-22
6
16
 
7
17
  ### Fixed
8
- - Added a command allowlist to `runAbortableCommand` so non-allowlisted executables are rejected before spawning, preventing arbitrary command injection.
9
- - Replaced dynamic string-based command spawning in Linux helpers with a typed `LinuxCommandName` union and dedicated `spawnLinuxCommand` switch.
18
+ - Added a command allowlist to `runAbortableCommand` so non-allowlisted executables are rejected before spawning, preventing arbitrary command injection ([2074e30](https://github.com/MasuRii/pi-smart-voice-notify/commit/2074e30)).
19
+ - Replaced dynamic string-based command spawning in Linux helpers with a typed `LinuxCommandName` union and dedicated `spawnLinuxCommand` switch ([2074e30](https://github.com/MasuRii/pi-smart-voice-notify/commit/2074e30)).
10
20
 
11
21
  ### Changed
12
- - Added `postinstall` hook that runs `patch-vulnerable-deps.mjs` when installed under `.pi/agent/extensions/`.
13
- - Pinned `protobufjs` 7.6.3, `ws` 8.21.0, and `uuid` 11.1.1 via npm `overrides` to resolve known vulnerabilities.
14
- - Added `@earendil-works/pi-coding-agent` as a devDependency for type-checking.
15
- - Updated README badge styling to for-the-badge, added platform badge and ko-fi support button.
16
- - Added `config-store-env-override` test to the test script.
22
+ - Added `postinstall` hook that runs `patch-vulnerable-deps.mjs` when installed under `.pi/agent/extensions/` ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
23
+ - Pinned `protobufjs` 7.6.3, `ws` 8.21.0, and `uuid` 11.1.1 via npm `overrides` to resolve known vulnerabilities ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
24
+ - Added `@earendil-works/pi-coding-agent` as a devDependency for type-checking ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
25
+ - Updated README badge styling to for-the-badge, added platform badge and ko-fi support button ([f66fff6](https://github.com/MasuRii/pi-smart-voice-notify/commit/f66fff6)).
26
+ - Added `config-store-env-override` test to the test script ([924e585](https://github.com/MasuRii/pi-smart-voice-notify/commit/924e585)).
17
27
 
18
28
  ## [0.5.3] - 2026-06-16
19
29
 
package/package.json CHANGED
@@ -1,81 +1,81 @@
1
- {
2
- "name": "pi-smart-voice-notify",
3
- "version": "0.5.4",
4
- "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "config/config.example.json",
14
- "assets",
15
- "README.md",
16
- "CHANGELOG.md",
17
- "LICENSE"
18
- ],
19
- "scripts": {
20
- "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
- "lint": "npm run build",
22
- "test": "node --experimental-strip-types --test test/config-store.test.ts test/config-store-no-global.test.ts test/config-store-env-override.test.ts test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
23
- "check": "npm run build && npm run test",
24
- "postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
25
- },
26
- "keywords": [
27
- "pi-package",
28
- "pi",
29
- "pi-extension",
30
- "pi-coding-agent",
31
- "coding-agent",
32
- "voice-notify",
33
- "desktop-notification",
34
- "notifications",
35
- "voice",
36
- "audio",
37
- "text-to-speech",
38
- "tts",
39
- "windows",
40
- "linux",
41
- "macos"
42
- ],
43
- "author": "MasuRii",
44
- "license": "MIT",
45
- "repository": {
46
- "type": "git",
47
- "url": "git+https://github.com/MasuRii/pi-smart-voice-notify.git"
48
- },
49
- "homepage": "https://github.com/MasuRii/pi-smart-voice-notify#readme",
50
- "bugs": {
51
- "url": "https://github.com/MasuRii/pi-smart-voice-notify/issues"
52
- },
53
- "engines": {
54
- "node": ">=24"
55
- },
56
- "publishConfig": {
57
- "access": "public"
58
- },
59
- "pi": {
60
- "extensions": [
61
- "./index.ts"
62
- ]
63
- },
64
- "peerDependencies": {
65
- "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0",
66
- "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
67
- },
68
- "dependencies": {
69
- "node-notifier": "^10.0.1",
70
- "undici": "^8.5.0"
71
- },
72
- "devDependencies": {
73
- "@types/node": "25.9.3",
74
- "@earendil-works/pi-coding-agent": "^0.79.7"
75
- },
76
- "overrides": {
77
- "protobufjs": "7.6.3",
78
- "ws": "8.21.0",
79
- "uuid": "11.1.1"
80
- }
81
- }
1
+ {
2
+ "name": "pi-smart-voice-notify",
3
+ "version": "0.6.0",
4
+ "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "assets",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
+ "lint": "npm run build",
22
+ "test": "node --experimental-strip-types --test test/config-store.test.ts test/config-store-no-global.test.ts test/config-store-env-override.test.ts test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
23
+ "check": "npm run build && npm run test",
24
+ "postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
25
+ },
26
+ "keywords": [
27
+ "pi-package",
28
+ "pi",
29
+ "pi-extension",
30
+ "pi-coding-agent",
31
+ "coding-agent",
32
+ "voice-notify",
33
+ "desktop-notification",
34
+ "notifications",
35
+ "voice",
36
+ "audio",
37
+ "text-to-speech",
38
+ "tts",
39
+ "windows",
40
+ "linux",
41
+ "macos"
42
+ ],
43
+ "author": "MasuRii",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/MasuRii/pi-smart-voice-notify.git"
48
+ },
49
+ "homepage": "https://github.com/MasuRii/pi-smart-voice-notify#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/MasuRii/pi-smart-voice-notify/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=24"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "pi": {
60
+ "extensions": [
61
+ "./index.ts"
62
+ ]
63
+ },
64
+ "peerDependencies": {
65
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
66
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
67
+ },
68
+ "dependencies": {
69
+ "node-notifier": "^10.0.1",
70
+ "undici": "^8.5.0"
71
+ },
72
+ "devDependencies": {
73
+ "@types/node": "26.1.0",
74
+ "@earendil-works/pi-coding-agent": "^0.80.3"
75
+ },
76
+ "overrides": {
77
+ "protobufjs": "7.6.3",
78
+ "ws": "8.21.0",
79
+ "uuid": "11.1.1"
80
+ }
81
+ }
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { basename } from "node:path";
3
3
 
4
+ import { buildCommandString, attachChildHandlers } from "./shared/index.ts";
5
+
4
6
  const ALLOWED_ABORTABLE_COMMANDS = new Set([
5
7
  "aplay",
6
8
  "edge-tts",
@@ -50,10 +52,6 @@ function stringifyError(error: unknown): string {
50
52
  return String(error);
51
53
  }
52
54
 
53
- function buildCommandString(command: string, args: readonly string[]): string {
54
- return args.length > 0 ? `${command} ${args.join(" ")}` : command;
55
- }
56
-
57
55
  function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void {
58
56
  if (child.killed) {
59
57
  return;
@@ -65,8 +63,10 @@ function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void
65
63
  return;
66
64
  }
67
65
  child.kill(force ? "SIGKILL" : "SIGTERM");
68
- } catch {
69
- // noop
66
+ } catch (error) {
67
+ // Killing a process that has already exited can throw on some platforms;
68
+ // the error is non-actionable for shutdown/abort flows.
69
+ void error;
70
70
  }
71
71
  }
72
72
 
@@ -99,7 +99,7 @@ export async function runAbortableCommand(
99
99
  }
100
100
 
101
101
  return await new Promise<AbortableCommandResult>((resolve) => {
102
- const child = spawn(normalizedCommand, [...args], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- normalizedCommand is checked against ALLOWED_ABORTABLE_COMMANDS (plus the current Node executable for tests) before spawn; args are passed as an array with shell disabled.
102
+ const child = spawn(normalizedCommand, Array.from(args), { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- normalizedCommand is checked against ALLOWED_ABORTABLE_COMMANDS (plus the current Node executable for tests) before spawn; args are passed as an array with shell disabled.
103
103
  env: options.env ?? process.env,
104
104
  cwd: options.cwd,
105
105
  });
@@ -148,19 +148,9 @@ export async function runAbortableCommand(
148
148
 
149
149
  options.signal?.addEventListener("abort", onAbort, { once: true });
150
150
 
151
- child.stdout?.on("data", (chunk: Buffer | string) => {
152
- stdout += chunk.toString();
153
- });
154
-
155
- child.stderr?.on("data", (chunk: Buffer | string) => {
156
- stderr += chunk.toString();
157
- });
158
-
159
- child.on("error", (error) => {
160
- spawnError = error;
161
- });
151
+ attachChildHandlers(child, (text: string) => { stdout += text; }, (text: string) => { stderr += text; }, (error: Error) => { spawnError = error; });
162
152
 
163
- child.on("close", (code) => {
153
+ child.on("close", (code: number | null) => {
164
154
  cleanup();
165
155
  resolve({
166
156
  code: code ?? (spawnError || timedOut || aborted ? 1 : 0),
@@ -1,5 +1,7 @@
1
1
  import { normalizeFloat } from "./config-store.ts";
2
2
  import { getErrorMessage } from "./logging.ts";
3
+ import { clampRoundedInt } from "./shared/index.ts";
4
+ import type { FlatAIMessageConfig } from "./types.ts";
3
5
 
4
6
  export const AI_EVENT_TYPES = [
5
7
  "idle",
@@ -24,20 +26,7 @@ export interface AIMessageContext {
24
26
  variables?: Record<string, string | number | boolean | null | undefined>;
25
27
  }
26
28
 
27
- export interface AIMessageConfig {
28
- enableAIMessages: boolean;
29
- aiEndpoint: string;
30
- aiModel: string;
31
- aiApiKey: string;
32
- aiTimeoutMs: number;
33
- aiTemperature: number;
34
- aiMaxTokens: number;
35
- aiFallbackToTemplates: boolean;
36
- personality: string;
37
- tone: string;
38
- enableMessageCache: boolean;
39
- messageCacheTtlMs: number;
40
- maxCacheEntries: number;
29
+ export interface AIMessageConfig extends FlatAIMessageConfig {
41
30
  templates: Partial<Record<CoreAIEventType, string[]>>;
42
31
  }
43
32
 
@@ -128,24 +117,17 @@ export const DEFAULT_AI_MESSAGE_CONFIG: AIMessageConfig = {
128
117
  templates: {},
129
118
  };
130
119
 
131
- function normalizePositiveInt(value: number, fallback: number, min: number, max: number): number {
132
- if (!Number.isFinite(value)) {
133
- return fallback;
134
- }
135
- return Math.min(max, Math.max(min, Math.round(value)));
136
- }
137
-
138
- function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
120
+ function normalizeAIMessageConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
139
121
  return {
140
122
  ...DEFAULT_AI_MESSAGE_CONFIG,
141
123
  ...overrides,
142
- aiTimeoutMs: normalizePositiveInt(
124
+ aiTimeoutMs: clampRoundedInt(
143
125
  overrides.aiTimeoutMs ?? DEFAULT_AI_MESSAGE_CONFIG.aiTimeoutMs,
144
126
  DEFAULT_AI_MESSAGE_CONFIG.aiTimeoutMs,
145
127
  1_000,
146
128
  60_000,
147
129
  ),
148
- aiMaxTokens: normalizePositiveInt(
130
+ aiMaxTokens: clampRoundedInt(
149
131
  overrides.aiMaxTokens ?? DEFAULT_AI_MESSAGE_CONFIG.aiMaxTokens,
150
132
  DEFAULT_AI_MESSAGE_CONFIG.aiMaxTokens,
151
133
  40,
@@ -157,13 +139,13 @@ function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageCon
157
139
  0,
158
140
  2,
159
141
  ),
160
- messageCacheTtlMs: normalizePositiveInt(
142
+ messageCacheTtlMs: clampRoundedInt(
161
143
  overrides.messageCacheTtlMs ?? DEFAULT_AI_MESSAGE_CONFIG.messageCacheTtlMs,
162
144
  DEFAULT_AI_MESSAGE_CONFIG.messageCacheTtlMs,
163
145
  5_000,
164
146
  600_000,
165
147
  ),
166
- maxCacheEntries: normalizePositiveInt(
148
+ maxCacheEntries: clampRoundedInt(
167
149
  overrides.maxCacheEntries ?? DEFAULT_AI_MESSAGE_CONFIG.maxCacheEntries,
168
150
  DEFAULT_AI_MESSAGE_CONFIG.maxCacheEntries,
169
151
  20,
@@ -304,15 +286,15 @@ export class AIMessageService {
304
286
  private readonly debugLog: (message: string, details?: Record<string, unknown>) => void;
305
287
 
306
288
  constructor(options: AIMessageServiceOptions = {}) {
307
- this.config = normalizeConfig(options.config);
289
+ this.config = normalizeAIMessageConfig(options.config);
308
290
  this.debugLog = options.debugLog ?? (() => {});
309
291
  }
310
292
 
311
- public updateConfig(overrides: Partial<AIMessageConfig>): void {
312
- this.config = normalizeConfig({ ...this.config, ...overrides });
293
+ public updateAIMessageConfig(overrides: Partial<AIMessageConfig>): void {
294
+ this.config = normalizeAIMessageConfig({ ...this.config, ...overrides });
313
295
  }
314
296
 
315
- public getConfig(): AIMessageConfig {
297
+ public getAIMessageConfig(): AIMessageConfig {
316
298
  return { ...this.config, templates: { ...this.config.templates } };
317
299
  }
318
300
 
@@ -449,7 +431,8 @@ export class AIMessageService {
449
431
  }
450
432
 
451
433
  while (this.cache.size > this.config.maxCacheEntries) {
452
- const oldestKey = this.cache.keys().next().value;
434
+ const next = this.cache.keys().next();
435
+ const oldestKey = typeof next.value === "string" ? next.value : undefined;
453
436
  if (!oldestKey) {
454
437
  break;
455
438
  }
@@ -2,6 +2,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
 
4
4
  import { resolvePiAgentDir } from "./agent-dir.ts";
5
+ import {
6
+ clampInt,
7
+ clampNumber,
8
+ normalizeFloat,
9
+ normalizeStringEnum,
10
+ normalizeStringEnumArray,
11
+ parseEnvBoolean,
12
+ readEnvFrom,
13
+ toRecord,
14
+ ENGINE_TTS_DEFAULTS,
15
+ } from "./shared/index.ts";
16
+
17
+ export { clampInt, clampNumber, normalizeFloat, toRecord };
5
18
  import type {
6
19
  ConcreteTTSEngine,
7
20
  MessageSet,
@@ -159,25 +172,7 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
159
172
  sapiPitch: "medium",
160
173
  sapiVolume: "loud",
161
174
 
162
- edgeVoice: "en-US-JennyNeural",
163
- edgeRate: "+10%",
164
- edgePitch: "+0Hz",
165
- edgeVolume: "+0%",
166
- espeakVoice: "en",
167
- espeakRate: 175,
168
- espeakPitch: 50,
169
- elevenLabsApiKey: "",
170
- elevenLabsVoiceId: "cgSgspJ2msm6clMCkdW9",
171
- elevenLabsModel: "eleven_turbo_v2_5",
172
- elevenLabsStability: 0.5,
173
- elevenLabsSimilarity: 0.75,
174
- elevenLabsStyle: 0.5,
175
- openaiTtsEndpoint: "",
176
- openaiTtsApiKey: "",
177
- openaiTtsModel: "tts-1",
178
- openaiTtsVoice: "alloy",
179
- openaiTtsFormat: "mp3",
180
- openaiTtsSpeed: 1,
175
+ ...ENGINE_TTS_DEFAULTS,
181
176
 
182
177
  idleSoundFile: "assets/soft-notification.mp3",
183
178
  permissionSoundFile: "assets/attention-alert.mp3",
@@ -261,71 +256,12 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
261
256
  debugLog: false,
262
257
  };
263
258
 
264
- export function toRecord(value: unknown): Record<string, unknown> {
265
- if (!value || typeof value !== "object" || Array.isArray(value)) {
266
- return {};
267
- }
268
- return value as Record<string, unknown>;
269
- }
270
-
271
259
  function readEnv(...keys: string[]): string {
272
- for (const key of keys) {
273
- const value = process.env[key];
274
- if (typeof value === "string" && value.trim().length > 0) {
275
- return value.trim();
276
- }
277
- }
278
- return "";
260
+ return readEnvFrom(...keys);
279
261
  }
280
262
 
281
263
  function parseEnvBool(value: string): boolean | undefined {
282
- if (!value) {
283
- return undefined;
284
- }
285
- const normalized = value.trim().toLowerCase();
286
- if (["1", "true", "yes", "on"].includes(normalized)) {
287
- return true;
288
- }
289
- if (["0", "false", "no", "off"].includes(normalized)) {
290
- return false;
291
- }
292
- return undefined;
293
- }
294
-
295
- function parseNumeric(value: unknown): number | undefined {
296
- if (typeof value === "number" && Number.isFinite(value)) {
297
- return value;
298
- }
299
- if (typeof value === "string" && value.trim().length > 0) {
300
- const parsed = Number(value.trim());
301
- if (Number.isFinite(parsed)) {
302
- return parsed;
303
- }
304
- }
305
- return undefined;
306
- }
307
-
308
- export function clampInt(value: unknown, fallback: number, min: number, max: number): number {
309
- const numeric = parseNumeric(value);
310
- if (numeric === undefined) {
311
- return fallback;
312
- }
313
- return Math.min(max, Math.max(min, Math.trunc(numeric)));
314
- }
315
-
316
- export function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
317
- const numeric = parseNumeric(value);
318
- if (numeric === undefined) {
319
- return fallback;
320
- }
321
- return Math.min(max, Math.max(min, numeric));
322
- }
323
-
324
- export function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
325
- if (!Number.isFinite(value)) {
326
- return fallback;
327
- }
328
- return Math.min(max, Math.max(min, value));
264
+ return parseEnvBoolean(value);
329
265
  }
330
266
 
331
267
  function boolOrDefault(value: unknown, fallback: boolean): boolean {
@@ -402,17 +338,11 @@ function normalizeStringArray(value: unknown): string[] {
402
338
  }
403
339
 
404
340
  function normalizeNotificationTypes(value: unknown, fallback: NotificationType[]): NotificationType[] {
405
- const candidates = normalizeStringArray(value)
406
- .map((entry) => entry.toLowerCase())
407
- .filter((entry): entry is NotificationType => NOTIFICATION_TYPE_VALUES.includes(entry as NotificationType));
408
- return candidates.length > 0 ? [...new Set(candidates)] : [...fallback];
341
+ return normalizeStringEnumArray(value, NOTIFICATION_TYPE_VALUES, fallback);
409
342
  }
410
343
 
411
344
  function normalizeFallbackChain(value: unknown, fallback: ConcreteTTSEngine[]): ConcreteTTSEngine[] {
412
- const candidates = normalizeStringArray(value)
413
- .map((entry) => entry.toLowerCase())
414
- .filter((entry): entry is ConcreteTTSEngine => CONCRETE_TTS_ENGINE_VALUES.includes(entry as ConcreteTTSEngine));
415
- return candidates.length > 0 ? [...new Set(candidates)] : [...fallback];
345
+ return normalizeStringEnumArray(value, CONCRETE_TTS_ENGINE_VALUES, fallback);
416
346
  }
417
347
 
418
348
  function normalizeTemplates(value: unknown): Partial<Record<string, string[]>> {
@@ -484,17 +414,11 @@ function isHttpUrl(url: string): boolean {
484
414
  }
485
415
 
486
416
  export function normalizeMode(value: unknown): NotificationMode {
487
- if (typeof value === "string" && NOTIFICATION_MODES.includes(value as NotificationMode)) {
488
- return value as NotificationMode;
489
- }
490
- return DEFAULT_CONFIG.notificationMode;
417
+ return normalizeStringEnum(value, NOTIFICATION_MODES, DEFAULT_CONFIG.notificationMode);
491
418
  }
492
419
 
493
420
  export function normalizeTtsEngine(value: unknown): TTSEngine {
494
- if (typeof value === "string" && TTS_ENGINE_VALUES.includes(value as TTSEngine)) {
495
- return value as TTSEngine;
496
- }
497
- return DEFAULT_CONFIG.ttsEngine;
421
+ return normalizeStringEnum(value, TTS_ENGINE_VALUES, DEFAULT_CONFIG.ttsEngine);
498
422
  }
499
423
 
500
424
  export function normalizeConfig(raw: unknown): VoiceNotifyConfig {