@madeinoz67/voice-server 0.1.3

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.
Files changed (86) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +258 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.claude/settings.local.json +23 -0
  11. package/.codanna/settings.toml +384 -0
  12. package/.env.development +18 -0
  13. package/.env.example +30 -0
  14. package/.github/codeql/config.yml +13 -0
  15. package/.github/codeql.yml +30 -0
  16. package/.github/dependabot.yml +11 -0
  17. package/.github/workflows/ci.yml +308 -0
  18. package/.specify/memory/constitution.md +223 -0
  19. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  20. package/.specify/scripts/bash/common.sh +156 -0
  21. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  22. package/.specify/scripts/bash/setup-plan.sh +61 -0
  23. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  24. package/.specify/templates/agent-file-template.md +28 -0
  25. package/.specify/templates/checklist-template.md +40 -0
  26. package/.specify/templates/plan-template.md +106 -0
  27. package/.specify/templates/spec-template.md +115 -0
  28. package/.specify/templates/tasks-template.md +261 -0
  29. package/AGENTPERSONALITIES.md +233 -0
  30. package/ATTRIBUTION.md +70 -0
  31. package/CHANGELOG.md +90 -0
  32. package/CLAUDE.md +50 -0
  33. package/Formula/madeinoz-voice-server.rb +106 -0
  34. package/README.md +451 -0
  35. package/bun.lock +212 -0
  36. package/cliff.toml +67 -0
  37. package/docs/KOKORO_VOICES.md +152 -0
  38. package/docs/MIGRATION.md +267 -0
  39. package/docs/VOICE_EXAMPLES.md +283 -0
  40. package/docs/VOICE_GUIDE.md +227 -0
  41. package/docs/VOICE_QUICK_REF.md +157 -0
  42. package/docs/agent-voices.md +114 -0
  43. package/docs/api.md +336 -0
  44. package/docs/assets/voice-server-architecture.png +0 -0
  45. package/docs/assets/voice-server-header.png +0 -0
  46. package/docs/assets/voice-server-pack-logo.png +0 -0
  47. package/docs/index.md +60 -0
  48. package/eslint.config.js +42 -0
  49. package/mkdocs.yml +55 -0
  50. package/package.json +28 -0
  51. package/reports/MLX_AUDIO_EVALUATION.md +302 -0
  52. package/reports/agent/2026-02-06-20-51-mlx-audio-qwen-tts-investigation.md +613 -0
  53. package/reports/agent/2026-02-06-Qwen3-TTS-API-Specification.md +446 -0
  54. package/reports/agent/2026-02-07-python-backend-removal-plan.md +790 -0
  55. package/scripts/generate-reference.ts +139 -0
  56. package/specs/001-qwen-tts/checklists/requirements.md +50 -0
  57. package/specs/001-qwen-tts/contracts/api.yaml +305 -0
  58. package/specs/001-qwen-tts/data-model.md +197 -0
  59. package/specs/001-qwen-tts/plan.md +236 -0
  60. package/specs/001-qwen-tts/quickstart.md +306 -0
  61. package/specs/001-qwen-tts/research.md +194 -0
  62. package/specs/001-qwen-tts/spec.md +135 -0
  63. package/specs/001-qwen-tts/tasks.md +305 -0
  64. package/src/ts/constants/KOKORO_VOICES.ts +141 -0
  65. package/src/ts/middleware/cors.ts +153 -0
  66. package/src/ts/middleware/rate-limiter.ts +200 -0
  67. package/src/ts/models/health.ts +45 -0
  68. package/src/ts/models/notification.ts +69 -0
  69. package/src/ts/models/pronunciation.ts +39 -0
  70. package/src/ts/models/tts.ts +54 -0
  71. package/src/ts/models/voice-config.ts +82 -0
  72. package/src/ts/server.ts +460 -0
  73. package/src/ts/services/mlx-tts-client.ts +337 -0
  74. package/src/ts/services/pronunciation.ts +209 -0
  75. package/src/ts/services/prosody-translator.ts +130 -0
  76. package/src/ts/services/voice-loader.ts +214 -0
  77. package/src/ts/utils/logger.ts +144 -0
  78. package/src/ts/utils/text-sanitizer.ts +118 -0
  79. package/tests/integration/api.test.ts +210 -0
  80. package/tests/mocks/index.ts +152 -0
  81. package/tests/ts/server.test.ts +11 -0
  82. package/tests/unit/middleware/cors.test.ts +146 -0
  83. package/tests/unit/models/validation.test.ts +332 -0
  84. package/tests/unit/services/pronunciation.test.ts +171 -0
  85. package/tests/unit/services/prosody-translator.test.ts +142 -0
  86. package/tsconfig.json +25 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Mock utilities for testing
3
+ */
4
+
5
+ import { mockDeep } from "bun:test";
6
+
7
+ /**
8
+ * Mock MLX-audio TTS process
9
+ */
10
+ export interface MockMLXProcess {
11
+ stdout: { readonly: () => ReadableStream<Uint8Array> };
12
+ stderr: { readonly: () => ReadableStream<Uint8Array> };
13
+ killed: boolean;
14
+ kill(): void;
15
+ }
16
+
17
+ /**
18
+ * Create a mock MLX TTS process
19
+ */
20
+ export function createMockMLXProcess(options: {
21
+ stdout?: string;
22
+ stderr?: string;
23
+ exitCode?: number;
24
+ }): MockMLXProcess {
25
+ const stdoutStream = new ReadableStream<Uint8Array>({
26
+ start(controller) {
27
+ if (options.stdout) {
28
+ controller.enqueue(new TextEncoder().encode(options.stdout));
29
+ }
30
+ controller.close();
31
+ },
32
+ });
33
+
34
+ const stderrStream = new ReadableStream<Uint8Array>({
35
+ start(controller) {
36
+ if (options.stderr) {
37
+ controller.enqueue(new TextEncoder().encode(options.stderr));
38
+ }
39
+ controller.close();
40
+ },
41
+ });
42
+
43
+ return {
44
+ stdout: { readonly: () => stdoutStream },
45
+ stderr: { readonly: () => stderrStream },
46
+ killed: false,
47
+ kill() {
48
+ this.killed = true;
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Mock file system operations
55
+ */
56
+ export const mockFS = {
57
+ existsSync: (path: string): boolean => {
58
+ // Build paths outside object literal for CodeQL compatibility
59
+ const homeVoicePath = process.env.HOME + "/.claude/VoiceServer/voices/pronunciations.json";
60
+ const mockExists: Record<string, boolean> = {
61
+ "/tmp/test.wav": true,
62
+ "/tmp/test.mp3": true,
63
+ [homeVoicePath]: false,
64
+ };
65
+ return mockExists[path] || false;
66
+ },
67
+ readFileSync: (path: string, encoding: string): string => {
68
+ const mockFiles: Record<string, string> = {
69
+ "/tmp/pronunciations.json": JSON.stringify({
70
+ pronunciations: [
71
+ { term: "TEST", pronunciation: "test pronunciation" },
72
+ ],
73
+ }),
74
+ };
75
+ return mockFiles[path] || "";
76
+ },
77
+ writeFileSync: (): void => {},
78
+ unlinkSync: (): void => {},
79
+ mkdirSync: (): void => {},
80
+ };
81
+
82
+ /**
83
+ * Mock subprocess for Bun.spawn
84
+ */
85
+ export function mockSpawn(cmd: string, args: string[]): MockMLXProcess {
86
+ if (cmd.endsWith("mlx_tts") || cmd.includes("python")) {
87
+ return createMockMLXProcess({
88
+ stdout: "",
89
+ stderr: "",
90
+ });
91
+ }
92
+ throw new Error(`Unknown mock command: ${cmd}`);
93
+ }
94
+
95
+ /**
96
+ * Test fixtures
97
+ */
98
+ export const fixtures = {
99
+ validWAVFile: Buffer.from([
100
+ // RIFF header
101
+ 0x52, 0x49, 0x46, 0x46, // "RIFF"
102
+ 0x24, 0x08, 0x00, 0x00, // file size
103
+ 0x57, 0x41, 0x56, 0x45, // "WAVE"
104
+ // fmt chunk
105
+ 0x66, 0x6D, 0x74, 0x20, // "fmt "
106
+ 0x10, 0x00, 0x00, 0x00, // chunk size
107
+ 0x01, 0x00, 0x01, 0x00, // audio format, channels
108
+ 0x40, 0x1F, 0x00, 0x00, // sample rate (8000)
109
+ 0x40, 0x1F, 0x00, 0x00, // byte rate
110
+ 0x01, 0x00, 0x08, 0x00, // block align, bits per sample
111
+ // data chunk
112
+ 0x64, 0x61, 0x74, 0x61, // "data"
113
+ 0x00, 0x08, 0x00, 0x00, // data size
114
+ // 1 second of silence at 8000Hz
115
+ ...Array(2048).fill(0x00),
116
+ ]),
117
+
118
+ validTTSRequest: {
119
+ text: "Hello world",
120
+ voice_id: "marrvin",
121
+ },
122
+
123
+ validNotificationRequest: {
124
+ message: "Test notification",
125
+ voice_id: "marrvin",
126
+ },
127
+
128
+ validProsodySettings: {
129
+ stability: 0.5,
130
+ style: 0.0,
131
+ speed: 1.0,
132
+ similarity_boost: 0.75,
133
+ use_speaker_boost: true,
134
+ },
135
+
136
+ validVoiceConfig: {
137
+ voice_id: "marrvin",
138
+ name: "Marvin",
139
+ language: "en",
140
+ gender: "male",
141
+ },
142
+ };
143
+
144
+ /**
145
+ * Mock logger that doesn't output to console
146
+ */
147
+ export const mockLogger = {
148
+ debug: () => {},
149
+ info: () => {},
150
+ warn: () => {},
151
+ error: () => {},
152
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Basic server tests
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+
7
+ describe("voice server", () => {
8
+ test("placeholder test", () => {
9
+ expect(true).toBe(true);
10
+ });
11
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * CORS middleware tests
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
+ import {
7
+ CORSMiddleware,
8
+ getCORSMiddleware,
9
+ resetCORSMiddleware,
10
+ } from "@/middleware/cors.js";
11
+ import type { CORSConfig } from "@/middleware/cors.js";
12
+
13
+ describe("CORSMiddleware", () => {
14
+ let middleware: CORSMiddleware;
15
+
16
+ beforeEach(() => {
17
+ resetCORSMiddleware();
18
+ middleware = new CORSMiddleware();
19
+ });
20
+
21
+ describe("isOriginAllowed", () => {
22
+ test("should allow localhost:8888", () => {
23
+ expect(middleware.isOriginAllowed("http://localhost:8888")).toBe(true);
24
+ });
25
+
26
+ test("should allow 127.0.0.1:8888", () => {
27
+ expect(middleware.isOriginAllowed("http://127.0.0.1:8888")).toBe(true);
28
+ });
29
+
30
+ test("should allow localhost:3000", () => {
31
+ expect(middleware.isOriginAllowed("http://localhost:3000")).toBe(true);
32
+ });
33
+
34
+ test("should reject unknown origins", () => {
35
+ expect(middleware.isOriginAllowed("http://evil.com")).toBe(false);
36
+ expect(middleware.isOriginAllowed("https://example.com")).toBe(false);
37
+ });
38
+
39
+ test("should allow null origin (same-origin)", () => {
40
+ expect(middleware.isOriginAllowed(null)).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("getCORSHeaders", () => {
45
+ test("should include standard CORS headers", () => {
46
+ const headers = middleware.getCORSHeaders("http://localhost:8888");
47
+ expect(headers["Access-Control-Allow-Methods"]).toBeDefined();
48
+ expect(headers["Access-Control-Allow-Headers"]).toBeDefined();
49
+ expect(headers["Access-Control-Max-Age"]).toBeDefined();
50
+ });
51
+
52
+ test("should include origin for allowed origins", () => {
53
+ const headers = middleware.getCORSHeaders("http://localhost:8888");
54
+ expect(headers["Access-Control-Allow-Origin"]).toBe("http://localhost:8888");
55
+ });
56
+
57
+ test("should not include origin for disallowed origins", () => {
58
+ const headers = middleware.getCORSHeaders("http://evil.com");
59
+ expect(headers["Access-Control-Allow-Origin"]).toBeUndefined();
60
+ });
61
+
62
+ test("should use wildcard for null origin", () => {
63
+ const headers = middleware.getCORSHeaders(null);
64
+ expect(headers["Access-Control-Allow-Origin"]).toBe("*");
65
+ });
66
+
67
+ test("should include credentials header when enabled", () => {
68
+ const authMiddleware = new CORSMiddleware({ allowCredentials: true });
69
+ const headers = authMiddleware.getCORSHeaders("http://localhost:8888");
70
+ expect(headers["Access-Control-Allow-Credentials"]).toBe("true");
71
+ });
72
+ });
73
+
74
+ describe("handlePreflight", () => {
75
+ test("should return 204 status for OPTIONS request", () => {
76
+ const response = middleware.handlePreflight("http://localhost:8888");
77
+ expect(response.status).toBe(204);
78
+ });
79
+
80
+ test("should include CORS headers in preflight response", () => {
81
+ const response = middleware.handlePreflight("http://localhost:8888");
82
+ expect(response.headers.get("Access-Control-Allow-Methods")).toBeDefined();
83
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:8888");
84
+ });
85
+ });
86
+
87
+ describe("addCorsHeaders", () => {
88
+ test("should add CORS headers to existing response", () => {
89
+ const response = new Response("OK", { status: 200 });
90
+ const corsResponse = middleware.addCorsHeaders(response, "http://localhost:8888");
91
+ expect(corsResponse.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:8888");
92
+ });
93
+ });
94
+
95
+ describe("createResponse", () => {
96
+ test("should create new response with CORS headers", () => {
97
+ const response = middleware.createResponse("Hello", {
98
+ status: 200,
99
+ origin: "http://localhost:8888",
100
+ });
101
+ expect(response.status).toBe(200);
102
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:8888");
103
+ });
104
+ });
105
+
106
+ describe("setConfig", () => {
107
+ test("should update configuration", () => {
108
+ middleware.setConfig({ allowCredentials: true });
109
+ const config = middleware.getConfig();
110
+ expect(config.allowCredentials).toBe(true);
111
+ });
112
+
113
+ test("should merge with existing config", () => {
114
+ middleware.setConfig({ allowCredentials: true });
115
+ const config = middleware.getConfig();
116
+ expect(config.allowedOrigins.length).toBeGreaterThan(0); // Defaults preserved
117
+ });
118
+ });
119
+
120
+ describe("getConfig", () => {
121
+ test("should return copy of config", () => {
122
+ const config1 = middleware.getConfig();
123
+ const config2 = middleware.getConfig();
124
+ expect(config1).toEqual(config2);
125
+ expect(config1).not.toBe(config2); // Different references
126
+ });
127
+ });
128
+ });
129
+
130
+ describe("getCORSMiddleware", () => {
131
+ afterEach(() => {
132
+ resetCORSMiddleware();
133
+ });
134
+
135
+ test("should return singleton instance", () => {
136
+ const middleware1 = getCORSMiddleware();
137
+ const middleware2 = getCORSMiddleware();
138
+ expect(middleware1).toBe(middleware2);
139
+ });
140
+
141
+ test("should create with custom config", () => {
142
+ const middleware = getCORSMiddleware({ allowCredentials: true });
143
+ const config = middleware.getConfig();
144
+ expect(config.allowCredentials).toBe(true);
145
+ });
146
+ });
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Model validation tests
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import { isValidNotificationRequest } from "@/models/notification.js";
7
+ import { isValidTTSRequest } from "@/models/tts.js";
8
+ import {
9
+ isValidVoiceConfig,
10
+ isValidProsody,
11
+ } from "@/models/voice-config.js";
12
+ import { isValidPronunciationRule } from "@/models/pronunciation.js";
13
+ import type { NotificationRequest } from "@/models/notification.js";
14
+ import type { TTSRequest } from "@/models/tts.js";
15
+ import type { VoiceConfig, ProsodySettings } from "@/models/voice-config.js";
16
+ import type { PronunciationRule } from "@/models/pronunciation.js";
17
+
18
+ describe("NotificationRequest validation", () => {
19
+ describe("isValidNotificationRequest", () => {
20
+ test("should validate correct request", () => {
21
+ const request: NotificationRequest = {
22
+ message: "Test notification",
23
+ voice_id: "marrvin",
24
+ };
25
+ expect(isValidNotificationRequest(request)).toBe(true);
26
+ });
27
+
28
+ test("should validate request with just message (minimal)", () => {
29
+ const request: NotificationRequest = {
30
+ message: "Test notification",
31
+ };
32
+ expect(isValidNotificationRequest(request)).toBe(true);
33
+ });
34
+
35
+ test("should require message", () => {
36
+ const request = { voice_id: "marrvin" } as Partial<NotificationRequest>;
37
+ expect(isValidNotificationRequest(request as NotificationRequest)).toBe(false);
38
+ });
39
+
40
+ test("should allow optional title", () => {
41
+ const request: NotificationRequest = {
42
+ message: "Test",
43
+ title: "Test Title",
44
+ };
45
+ expect(isValidNotificationRequest(request)).toBe(true);
46
+ });
47
+
48
+ test("should reject title over 100 chars", () => {
49
+ const request: NotificationRequest = {
50
+ message: "Test",
51
+ title: "a".repeat(101),
52
+ };
53
+ expect(isValidNotificationRequest(request)).toBe(false);
54
+ });
55
+
56
+ test("should reject message over 500 chars", () => {
57
+ const request: NotificationRequest = {
58
+ message: "a".repeat(501),
59
+ };
60
+ expect(isValidNotificationRequest(request)).toBe(false);
61
+ });
62
+
63
+ test("should reject both voice_id and voice_name specified", () => {
64
+ const request: NotificationRequest = {
65
+ message: "Test",
66
+ voice_id: "marrvin",
67
+ voice_name: "marvin",
68
+ };
69
+ expect(isValidNotificationRequest(request)).toBe(false);
70
+ });
71
+
72
+ test("should allow voice_id alone", () => {
73
+ const request: NotificationRequest = {
74
+ message: "Test",
75
+ voice_id: "marrvin",
76
+ };
77
+ expect(isValidNotificationRequest(request)).toBe(true);
78
+ });
79
+
80
+ test("should allow voice_name alone", () => {
81
+ const request: NotificationRequest = {
82
+ message: "Test",
83
+ voice_name: "marvin",
84
+ };
85
+ expect(isValidNotificationRequest(request)).toBe(true);
86
+ });
87
+ });
88
+ });
89
+
90
+ describe("TTSRequest validation", () => {
91
+ describe("isValidTTSRequest", () => {
92
+ test("should validate correct request", () => {
93
+ const request: TTSRequest = {
94
+ text: "Hello world",
95
+ voice: "marrvin",
96
+ prosody_instruction: "speak normally",
97
+ speed: 1.0,
98
+ };
99
+ expect(isValidTTSRequest(request)).toBe(true);
100
+ });
101
+
102
+ test("should require text", () => {
103
+ const request = {
104
+ voice: "marrvin",
105
+ prosody_instruction: "speak normally",
106
+ } as Partial<TTSRequest>;
107
+ expect(isValidTTSRequest(request as TTSRequest)).toBe(false);
108
+ });
109
+
110
+ test("should require voice", () => {
111
+ const request = {
112
+ text: "Hello",
113
+ prosody_instruction: "speak normally",
114
+ } as Partial<TTSRequest>;
115
+ expect(isValidTTSRequest(request as TTSRequest)).toBe(false);
116
+ });
117
+
118
+ test("should require prosody_instruction", () => {
119
+ const request = {
120
+ text: "Hello",
121
+ voice: "marrvin",
122
+ } as Partial<TTSRequest>;
123
+ expect(isValidTTSRequest(request as TTSRequest)).toBe(false);
124
+ });
125
+
126
+ test("should reject empty text", () => {
127
+ const request: TTSRequest = {
128
+ text: "",
129
+ voice: "marrvin",
130
+ prosody_instruction: "speak normally",
131
+ };
132
+ expect(isValidTTSRequest(request)).toBe(false);
133
+ });
134
+
135
+ test("should reject whitespace-only text", () => {
136
+ const request: TTSRequest = {
137
+ text: " ",
138
+ voice: "marrvin",
139
+ prosody_instruction: "speak normally",
140
+ };
141
+ expect(isValidTTSRequest(request)).toBe(false);
142
+ });
143
+
144
+ test("should allow optional output_format", () => {
145
+ const request: TTSRequest = {
146
+ text: "Hello",
147
+ voice: "marrvin",
148
+ prosody_instruction: "speak normally",
149
+ output_format: "wav",
150
+ };
151
+ expect(isValidTTSRequest(request)).toBe(true);
152
+ });
153
+
154
+ test("should reject speed below 0.5", () => {
155
+ const request: TTSRequest = {
156
+ text: "Hello",
157
+ voice: "marrvin",
158
+ prosody_instruction: "speak normally",
159
+ speed: 0.4,
160
+ };
161
+ expect(isValidTTSRequest(request)).toBe(false);
162
+ });
163
+
164
+ test("should reject speed above 2.0", () => {
165
+ const request: TTSRequest = {
166
+ text: "Hello",
167
+ voice: "marrvin",
168
+ prosody_instruction: "speak normally",
169
+ speed: 2.1,
170
+ };
171
+ expect(isValidTTSRequest(request)).toBe(false);
172
+ });
173
+ });
174
+ });
175
+
176
+ describe("ProsodySettings validation", () => {
177
+ describe("isValidProsody", () => {
178
+ test("should validate correct settings", () => {
179
+ const settings: ProsodySettings = {
180
+ stability: 0.5,
181
+ style: 0.3,
182
+ speed: 1.0,
183
+ similarity_boost: 0.75,
184
+ use_speaker_boost: true,
185
+ };
186
+ expect(isValidProsody(settings)).toBe(true);
187
+ });
188
+
189
+ test("should allow partial settings", () => {
190
+ const settings = { stability: 0.5 };
191
+ expect(isValidProsody(settings as ProsodySettings)).toBe(true);
192
+ });
193
+
194
+ test("should allow empty settings", () => {
195
+ const settings = {};
196
+ expect(isValidProsody(settings as ProsodySettings)).toBe(true);
197
+ });
198
+
199
+ test("should reject stability out of range", () => {
200
+ const settings = { stability: -0.1 };
201
+ expect(isValidProsody(settings as ProsodySettings)).toBe(false);
202
+
203
+ const settings2 = { stability: 1.1 };
204
+ expect(isValidProsody(settings2 as ProsodySettings)).toBe(false);
205
+ });
206
+
207
+ test("should reject speed out of range", () => {
208
+ const settings = { speed: 0.05 };
209
+ expect(isValidProsody(settings as ProsodySettings)).toBe(false);
210
+
211
+ const settings2 = { speed: 2.5 };
212
+ expect(isValidProsody(settings2 as ProsodySettings)).toBe(false);
213
+ });
214
+
215
+ test("should reject volume out of range", () => {
216
+ const settings = { volume: -0.1 };
217
+ expect(isValidProsody(settings as ProsodySettings)).toBe(false);
218
+
219
+ const settings2 = { volume: 1.5 };
220
+ expect(isValidProsody(settings2 as ProsodySettings)).toBe(false);
221
+ });
222
+ });
223
+ });
224
+
225
+ describe("VoiceConfig validation", () => {
226
+ describe("isValidVoiceConfig", () => {
227
+ test("should validate correct config", () => {
228
+ const config: VoiceConfig = {
229
+ voice_id: "marrvin",
230
+ voice_name: "Marvin",
231
+ description: "A test voice",
232
+ type: "built-in",
233
+ stability: 0.5,
234
+ similarity_boost: 0.75,
235
+ style: 0.0,
236
+ speed: 1.0,
237
+ use_speaker_boost: true,
238
+ };
239
+ expect(isValidVoiceConfig(config)).toBe(true);
240
+ });
241
+
242
+ test("should require voice_id", () => {
243
+ const config = { voice_name: "Test" } as Partial<VoiceConfig>;
244
+ expect(isValidVoiceConfig(config as VoiceConfig)).toBe(false);
245
+ });
246
+
247
+ test("should require voice_name", () => {
248
+ const config = { voice_id: "test" } as Partial<VoiceConfig>;
249
+ expect(isValidVoiceConfig(config as VoiceConfig)).toBe(false);
250
+ });
251
+
252
+ test("should reject invalid voice_id format", () => {
253
+ const config: Partial<VoiceConfig> = {
254
+ voice_id: "invalid id!",
255
+ voice_name: "Test",
256
+ };
257
+ expect(isValidVoiceConfig(config)).toBe(false);
258
+ });
259
+
260
+ test("should reject stability out of range", () => {
261
+ const config: Partial<VoiceConfig> = {
262
+ voice_id: "test",
263
+ voice_name: "Test",
264
+ stability: 1.5,
265
+ };
266
+ expect(isValidVoiceConfig(config)).toBe(false);
267
+ });
268
+
269
+ test("should reject speed out of range", () => {
270
+ const config: Partial<VoiceConfig> = {
271
+ voice_id: "test",
272
+ voice_name: "Test",
273
+ speed: 3.0,
274
+ };
275
+ expect(isValidVoiceConfig(config)).toBe(false);
276
+ });
277
+ });
278
+ });
279
+
280
+ describe("PronunciationRule validation", () => {
281
+ describe("isValidPronunciationRule", () => {
282
+ test("should validate correct rule", () => {
283
+ const rule: PronunciationRule = {
284
+ term: "API",
285
+ pronunciation: "A P I",
286
+ };
287
+ expect(isValidPronunciationRule(rule)).toBe(true);
288
+ });
289
+
290
+ test("should require term", () => {
291
+ const rule = { pronunciation: "test" } as Partial<PronunciationRule>;
292
+ expect(isValidPronunciationRule(rule as PronunciationRule)).toBe(false);
293
+ });
294
+
295
+ test("should require pronunciation", () => {
296
+ const rule = { term: "test" } as Partial<PronunciationRule>;
297
+ expect(isValidPronunciationRule(rule as PronunciationRule)).toBe(false);
298
+ });
299
+
300
+ test("should reject empty term", () => {
301
+ const rule: PronunciationRule = {
302
+ term: "",
303
+ pronunciation: "test",
304
+ };
305
+ expect(isValidPronunciationRule(rule)).toBe(false);
306
+ });
307
+
308
+ test("should reject whitespace-only term", () => {
309
+ const rule: PronunciationRule = {
310
+ term: " ",
311
+ pronunciation: "test",
312
+ };
313
+ expect(isValidPronunciationRule(rule)).toBe(false);
314
+ });
315
+
316
+ test("should reject empty pronunciation", () => {
317
+ const rule: PronunciationRule = {
318
+ term: "test",
319
+ pronunciation: "",
320
+ };
321
+ expect(isValidPronunciationRule(rule)).toBe(false);
322
+ });
323
+
324
+ test("should reject whitespace-only pronunciation", () => {
325
+ const rule: PronunciationRule = {
326
+ term: "test",
327
+ pronunciation: " ",
328
+ };
329
+ expect(isValidPronunciationRule(rule)).toBe(false);
330
+ });
331
+ });
332
+ });