@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,214 @@
1
+ /**
2
+ * Voice Configuration Loader
3
+ * Parses AGENTPERSONALITIES.md and caches voice configurations
4
+ * Supports numeric voice IDs (1-54) mapped to Kokoro voices
5
+ */
6
+
7
+ import type { VoiceConfig } from "@/models/voice-config.js";
8
+ import { logger } from "@/utils/logger.js";
9
+ import { getKokoroVoice, getVoiceInfo, type KokoroVoice } from "@/constants/KOKORO_VOICES.js";
10
+
11
+ /**
12
+ * Voice registry entry from AGENTPERSONALITIES.md
13
+ */
14
+ interface VoiceRegistryEntry {
15
+ name: string;
16
+ voice_id: string;
17
+ characteristics: string[];
18
+ description: string;
19
+ prosody?: {
20
+ stability?: number;
21
+ similarity_boost?: number;
22
+ style?: number;
23
+ speed?: number;
24
+ use_speaker_boost?: boolean;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Parsed AGENTPERSONALITIES.md structure
30
+ */
31
+ interface AgentPersonalities {
32
+ voice_mappings: {
33
+ voice_registry: Record<string, VoiceRegistryEntry>;
34
+ default: string;
35
+ default_voice_id: string;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Voice loader cache
41
+ */
42
+ interface VoiceCache {
43
+ voices: Map<string, VoiceConfig>;
44
+ defaultVoiceId: string;
45
+ lastLoaded: number;
46
+ }
47
+
48
+ /**
49
+ * Voice loader service
50
+ * Loads and caches voice configurations from AGENTPERSONALITIES.md
51
+ * Supports numeric voice IDs (1-54) for Kokoro voices
52
+ */
53
+ class VoiceLoaderService {
54
+ private cache: VoiceCache | null = null;
55
+ private cachePath: string;
56
+ private personalitiesPath: string;
57
+
58
+ constructor() {
59
+ // Path to AGENTPERSONALITIES.md in PAI skills
60
+ this.personalitiesPath = `${process.env.HOME}/.claude/skills/Agents/AgentPersonalities.md`;
61
+ this.cachePath = `${process.env.HOME}/.claude/skills/Agents/Data/Traits.yaml`;
62
+ }
63
+
64
+ /**
65
+ * Resolve voice_id to Kokoro voice name
66
+ * @param voiceId - Numeric voice ID (1-54) or string identifier
67
+ * @returns Kokoro voice preset name (e.g., "af_heart")
68
+ */
69
+ resolveKokoroVoice(voiceId: string): string {
70
+ return getKokoroVoice(voiceId);
71
+ }
72
+
73
+ /**
74
+ * Get voice information for a numeric ID
75
+ * @param voiceId - Numeric voice ID (1-54)
76
+ * @returns Voice info or undefined
77
+ */
78
+ getVoiceInfo(voiceId: string): KokoroVoice | undefined {
79
+ return getVoiceInfo(voiceId);
80
+ }
81
+
82
+ /**
83
+ * Get all available Kokoro voices
84
+ * @returns Array of all Kokoro voice information
85
+ */
86
+ getAllKokoroVoices(): readonly KokoroVoice[] {
87
+ const { KOKORO_VOICES } = require("@/constants/KOKORO_VOICES.js");
88
+ return KOKORO_VOICES;
89
+ }
90
+
91
+ /**
92
+ * Load voice configurations from AGENTPERSONALITIES.md
93
+ */
94
+ async loadVoices(): Promise<Map<string, VoiceConfig>> {
95
+ // Return cached voices if available and recent (< 5 minutes old)
96
+ if (this.cache && Date.now() - this.cache.lastLoaded < 300000) {
97
+ logger.debug("Using cached voice configurations");
98
+ return this.cache.voices;
99
+ }
100
+
101
+ logger.info("Loading voice configurations from AGENTPERSONALITIES.md");
102
+
103
+ try {
104
+ // For now, return a basic voice mapping
105
+ // TODO: Implement full AGENTPERSONALITIES.md parser
106
+ const voices = new Map<string, VoiceConfig>();
107
+
108
+ // Default voices based on Traits.yaml
109
+ voices.set("marrvin", {
110
+ voice_id: "marrvin",
111
+ voice_name: "Default",
112
+ description: "Default voice",
113
+ type: "built-in",
114
+ stability: 0.5,
115
+ similarity_boost: 0.75,
116
+ style: 0.0,
117
+ speed: 1.0,
118
+ use_speaker_boost: true,
119
+ });
120
+
121
+ voices.set("marlin", {
122
+ voice_id: "marlin",
123
+ voice_name: "Marlin",
124
+ description: "Alternative voice",
125
+ type: "built-in",
126
+ stability: 0.6,
127
+ similarity_boost: 0.75,
128
+ style: 0.1,
129
+ speed: 1.0,
130
+ use_speaker_boost: true,
131
+ });
132
+
133
+ voices.set("daniel", {
134
+ voice_id: "daniel",
135
+ voice_name: "Daniel",
136
+ description: "Daniel voice",
137
+ type: "built-in",
138
+ stability: 0.7,
139
+ similarity_boost: 0.85,
140
+ style: 0.1,
141
+ speed: 0.95,
142
+ use_speaker_boost: true,
143
+ });
144
+
145
+ // Cache the voices
146
+ this.cache = {
147
+ voices,
148
+ defaultVoiceId: "marrvin",
149
+ lastLoaded: Date.now(),
150
+ };
151
+
152
+ logger.info(`Loaded ${voices.size} voice configurations`);
153
+ return voices;
154
+ } catch (error) {
155
+ logger.error("Failed to load voice configurations", error as Error);
156
+ // Return empty map on error
157
+ return new Map();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get a specific voice configuration by ID
163
+ */
164
+ async getVoice(voiceId: string): Promise<VoiceConfig | undefined> {
165
+ const voices = await this.loadVoices();
166
+ return voices.get(voiceId);
167
+ }
168
+
169
+ /**
170
+ * Get the default voice ID
171
+ */
172
+ async getDefaultVoiceId(): Promise<string> {
173
+ if (!this.cache) {
174
+ await this.loadVoices();
175
+ }
176
+ return this.cache?.defaultVoiceId || "marrvin";
177
+ }
178
+
179
+ /**
180
+ * Get all available voice IDs
181
+ */
182
+ async getAvailableVoices(): Promise<string[]> {
183
+ const voices = await this.loadVoices();
184
+ return Array.from(voices.keys());
185
+ }
186
+
187
+ /**
188
+ * Clear the voice cache (force reload on next access)
189
+ */
190
+ clearCache(): void {
191
+ logger.debug("Clearing voice cache");
192
+ this.cache = null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Global voice loader instance
198
+ */
199
+ let voiceLoaderInstance: VoiceLoaderService | null = null;
200
+
201
+ /**
202
+ * Get the voice loader service instance
203
+ */
204
+ export function getVoiceLoader(): VoiceLoaderService {
205
+ if (!voiceLoaderInstance) {
206
+ voiceLoaderInstance = new VoiceLoaderService();
207
+ }
208
+ return voiceLoaderInstance;
209
+ }
210
+
211
+ /**
212
+ * Export types for use in other modules
213
+ */
214
+ export type { VoiceRegistryEntry, AgentPersonalities, VoiceCache };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Structured logger utility
3
+ * Provides consistent logging across the application
4
+ */
5
+
6
+ /**
7
+ * Log level enumeration
8
+ */
9
+ export enum LogLevel {
10
+ DEBUG = 0,
11
+ INFO = 1,
12
+ WARN = 2,
13
+ ERROR = 3,
14
+ }
15
+
16
+ /**
17
+ * Log entry structure
18
+ */
19
+ export interface LogEntry {
20
+ level: LogLevel;
21
+ message: string;
22
+ timestamp: Date;
23
+ context?: Record<string, unknown>;
24
+ error?: Error;
25
+ }
26
+
27
+ /**
28
+ * Logger configuration
29
+ */
30
+ export interface LoggerConfig {
31
+ level: LogLevel;
32
+ includeTimestamp: boolean;
33
+ includeContext: boolean;
34
+ }
35
+
36
+ /**
37
+ * Current logger configuration
38
+ */
39
+ let config: LoggerConfig = {
40
+ level: LogLevel.INFO,
41
+ includeTimestamp: true,
42
+ includeContext: true,
43
+ };
44
+
45
+ /**
46
+ * Format log level for output
47
+ */
48
+ function formatLevel(level: LogLevel): string {
49
+ switch (level) {
50
+ case LogLevel.DEBUG:
51
+ return "DEBUG";
52
+ case LogLevel.INFO:
53
+ return "INFO";
54
+ case LogLevel.WARN:
55
+ return "WARN";
56
+ case LogLevel.ERROR:
57
+ return "ERROR";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Format log entry for output
63
+ */
64
+ function formatLogEntry(entry: LogEntry): string {
65
+ const parts: string[] = [];
66
+
67
+ if (config.includeTimestamp) {
68
+ parts.push(entry.timestamp.toISOString());
69
+ }
70
+
71
+ parts.push(`[${formatLevel(entry.level)}]`);
72
+ parts.push(entry.message);
73
+
74
+ if (entry.context && config.includeContext && Object.keys(entry.context).length > 0) {
75
+ parts.push(JSON.stringify(entry.context));
76
+ }
77
+
78
+ if (entry.error) {
79
+ parts.push(`\n${entry.error.stack || entry.error.message}`);
80
+ }
81
+
82
+ return parts.join(" ");
83
+ }
84
+
85
+ /**
86
+ * Core logging function
87
+ */
88
+ function log(level: LogLevel, message: string, context?: Record<string, unknown>, error?: Error): void {
89
+ if (level < config.level) return;
90
+
91
+ const entry: LogEntry = {
92
+ level,
93
+ message,
94
+ timestamp: new Date(),
95
+ context,
96
+ error,
97
+ };
98
+
99
+ const output = formatLogEntry(entry);
100
+
101
+ switch (level) {
102
+ case LogLevel.DEBUG:
103
+ case LogLevel.INFO:
104
+ console.log(output);
105
+ break;
106
+ case LogLevel.WARN:
107
+ console.warn(output);
108
+ break;
109
+ case LogLevel.ERROR:
110
+ console.error(output);
111
+ break;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Logger API
117
+ */
118
+ export const logger = {
119
+ debug(message: string, context?: Record<string, unknown>): void {
120
+ log(LogLevel.DEBUG, message, context);
121
+ },
122
+
123
+ info(message: string, context?: Record<string, unknown>): void {
124
+ log(LogLevel.INFO, message, context);
125
+ },
126
+
127
+ warn(message: string, context?: Record<string, unknown>): void {
128
+ log(LogLevel.WARN, message, context);
129
+ },
130
+
131
+ error(message: string, error?: Error, context?: Record<string, unknown>): void {
132
+ log(LogLevel.ERROR, message, context, error);
133
+ },
134
+
135
+ setLevel(level: LogLevel): void {
136
+ config.level = level;
137
+ },
138
+
139
+ configure(newConfig: Partial<LoggerConfig>): void {
140
+ config = { ...config, ...newConfig };
141
+ },
142
+ };
143
+
144
+ export default logger;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Text sanitization utility
3
+ * Input validation and sanitization for TTS text
4
+ */
5
+
6
+ /**
7
+ * Sanitization options
8
+ */
9
+ export interface SanitizeOptions {
10
+ /** Maximum length (default: 500) */
11
+ maxLength?: number;
12
+ /** Remove HTML tags (default: true) */
13
+ stripHtml?: boolean;
14
+ /** Remove shell metacharacters (default: true) */
15
+ stripShellChars?: boolean;
16
+ /** Normalize whitespace (default: true) */
17
+ normalizeWhitespace?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Default sanitization options
22
+ */
23
+ const DEFAULT_OPTIONS: SanitizeOptions = {
24
+ maxLength: 500,
25
+ stripHtml: true,
26
+ stripShellChars: true,
27
+ normalizeWhitespace: true,
28
+ };
29
+
30
+ /**
31
+ * HTML tag patterns to remove
32
+ *
33
+ * We strip all angle brackets rather than trying to parse HTML tags,
34
+ * which avoids incomplete multi-character sanitization issues.
35
+ */
36
+ const HTML_TAG_PATTERN = /[<>]/g;
37
+
38
+ /**
39
+ * Shell metacharacter patterns to remove
40
+ */
41
+ const SHELL_CHARS_PATTERN = /[\$`'"\\;|&()<>]/g;
42
+
43
+ /**
44
+ * Whitespace normalization pattern
45
+ */
46
+ const WHITESPACE_PATTERN = /\s+/g;
47
+
48
+ /**
49
+ * Script tag detection (conservative)
50
+ *
51
+ * Detects any <script ...> or </script ...> tag, including variants with
52
+ * whitespace around the tag name or attributes within the tag, such as `</script >`
53
+ * or `</script foo="bar">`.
54
+ */
55
+ const SCRIPT_PATTERN = /<\s*script\b[^>]*>|<\s*\/\s*script\b[^>]*>/i;
56
+
57
+ /**
58
+ * Sanitize text for TTS input
59
+ *
60
+ * Removes potentially dangerous content and normalizes text
61
+ */
62
+ export function sanitizeText(text: string, options: SanitizeOptions = {}): string {
63
+ const opts = { ...DEFAULT_OPTIONS, ...options };
64
+ let result = text;
65
+
66
+ // Check for script tags first (security)
67
+ if (opts.stripHtml && SCRIPT_PATTERN.test(result)) {
68
+ throw new Error("Text contains script tags and was rejected");
69
+ }
70
+
71
+ // Remove HTML tags
72
+ if (opts.stripHtml) {
73
+ result = result.replace(HTML_TAG_PATTERN, "");
74
+ }
75
+
76
+ // Remove shell metacharacters
77
+ if (opts.stripShellChars) {
78
+ result = result.replace(SHELL_CHARS_PATTERN, "");
79
+ }
80
+
81
+ // Normalize whitespace
82
+ if (opts.normalizeWhitespace) {
83
+ result = result.replace(WHITESPACE_PATTERN, " ").trim();
84
+ }
85
+
86
+ // Enforce max length
87
+ if (opts.maxLength && result.length > opts.maxLength) {
88
+ result = result.substring(0, opts.maxLength).trim();
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Validate text is safe for TTS
96
+ */
97
+ export function isValidText(text: string): boolean {
98
+ if (!text || text.trim().length === 0) return false;
99
+
100
+ // Check for dangerous patterns
101
+ if (SCRIPT_PATTERN.test(text)) return false;
102
+
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Sanitize title for macOS notification
108
+ */
109
+ export function sanitizeTitle(title: string): string {
110
+ return sanitizeText(title, { maxLength: 100 });
111
+ }
112
+
113
+ /**
114
+ * Sanitize message for TTS
115
+ */
116
+ export function sanitizeMessage(message: string): string {
117
+ return sanitizeText(message, { maxLength: 500 });
118
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Integration tests for API endpoints
3
+ *
4
+ * Note: These tests require MLX-audio to be installed.
5
+ * They will be skipped if MLX-audio is not available.
6
+ */
7
+
8
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
9
+
10
+ // Test server configuration
11
+ const SERVER_HOST = "127.0.0.1";
12
+ const SERVER_PORT = 8899; // Use different port for tests
13
+ const SERVER_URL = `http://${SERVER_HOST}:${SERVER_PORT}`;
14
+
15
+ let serverProcess: ReturnType<typeof Bun.spawn> | null = null;
16
+
17
+ // Check if MLX-audio is available
18
+ async function checkMLXAudio(): Promise<boolean> {
19
+ try {
20
+ const proc = Bun.spawn(["which", "mlx_tts"], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ });
24
+ await proc.exited;
25
+ return proc.exitCode === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ const hasMLXAudio = await checkMLXAudio();
32
+
33
+ describe.skipIf(!hasMLXAudio)("Voice Server API Integration Tests (skipped - MLX-audio not available)", () => {
34
+ beforeAll(async () => {
35
+ // Start test server
36
+ serverProcess = Bun.spawn({
37
+ cmd: ["bun", "run", "src/ts/server.ts"],
38
+ env: {
39
+ ...process.env,
40
+ PORT: SERVER_PORT.toString(),
41
+ NODE_ENV: "test",
42
+ },
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ });
46
+
47
+ // Wait for server to be ready
48
+ let retries = 50;
49
+ while (retries > 0) {
50
+ try {
51
+ const response = await fetch(`${SERVER_URL}/health`);
52
+ if (response.ok) {
53
+ break;
54
+ }
55
+ } catch {
56
+ // Server not ready yet
57
+ }
58
+ await new Promise(resolve => setTimeout(resolve, 100));
59
+ retries--;
60
+ }
61
+
62
+ if (retries === 0) {
63
+ throw new Error("Server failed to start");
64
+ }
65
+ }, 10000);
66
+
67
+ afterAll(() => {
68
+ if (serverProcess) {
69
+ serverProcess.kill();
70
+ }
71
+ });
72
+
73
+ describe("GET /health", () => {
74
+ test("should return health status", async () => {
75
+ const response = await fetch(`${SERVER_URL}/health`);
76
+ expect(response.status).toBe(200);
77
+
78
+ const data = await response.json();
79
+ expect(data.status).toBe("healthy");
80
+ expect(data.port).toBe(SERVER_PORT);
81
+ expect(data.voice_system).toBeDefined();
82
+ });
83
+ });
84
+
85
+ describe("POST /notify", () => {
86
+ test("should accept valid notification request", async () => {
87
+ const response = await fetch(`${SERVER_URL}/notify`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({
91
+ message: "Test notification",
92
+ voice_id: "marrvin",
93
+ }),
94
+ });
95
+
96
+ expect(response.status).toBe(200);
97
+ const data = await response.json();
98
+ expect(data.status).toBe("success");
99
+ });
100
+
101
+ test("should reject missing message", async () => {
102
+ const response = await fetch(`${SERVER_URL}/notify`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ voice_id: "marrvin" }),
106
+ });
107
+
108
+ expect(response.status).toBe(400);
109
+ });
110
+
111
+ test("should reject missing voice_id", async () => {
112
+ const response = await fetch(`${SERVER_URL}/notify`, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ message: "Test" }),
116
+ });
117
+
118
+ expect(response.status).toBe(400);
119
+ });
120
+
121
+ test("should reject invalid JSON", async () => {
122
+ const response = await fetch(`${SERVER_URL}/notify`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: "invalid json",
126
+ });
127
+
128
+ expect(response.status).toBe(400);
129
+ });
130
+ });
131
+
132
+ describe("POST /tts", () => {
133
+ test("should accept valid TTS request", async () => {
134
+ const response = await fetch(`${SERVER_URL}/tts`, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({
138
+ text: "Hello world",
139
+ voice_id: "marrvin",
140
+ }),
141
+ });
142
+
143
+ // Note: This may fail if MLX-audio is not configured
144
+ // Status could be 200 (success) or 500 (MLX not available)
145
+ expect([200, 500]).toContain(response.status);
146
+ });
147
+
148
+ test("should reject missing text", async () => {
149
+ const response = await fetch(`${SERVER_URL}/tts`, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({ voice_id: "marrvin" }),
153
+ });
154
+
155
+ expect(response.status).toBe(400);
156
+ });
157
+
158
+ test("should reject missing voice_id", async () => {
159
+ const response = await fetch(`${SERVER_URL}/tts`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ text: "Hello" }),
163
+ });
164
+
165
+ expect(response.status).toBe(400);
166
+ });
167
+
168
+ test("should reject empty text", async () => {
169
+ const response = await fetch(`${SERVER_URL}/tts`, {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({
173
+ text: "",
174
+ voice_id: "marrvin",
175
+ }),
176
+ });
177
+
178
+ expect(response.status).toBe(400);
179
+ });
180
+ });
181
+
182
+ describe("CORS", () => {
183
+ test("should include CORS headers for allowed origins", async () => {
184
+ const response = await fetch(`${SERVER_URL}/health`, {
185
+ headers: { Origin: "http://localhost:3000" },
186
+ });
187
+
188
+ expect(response.headers.get("Access-Control-Allow-Methods")).toBeDefined();
189
+ });
190
+
191
+ test("should handle OPTIONS preflight", async () => {
192
+ const response = await fetch(`${SERVER_URL}/notify`, {
193
+ method: "OPTIONS",
194
+ headers: {
195
+ Origin: "http://localhost:3000",
196
+ "Access-Control-Request-Method": "POST",
197
+ },
198
+ });
199
+
200
+ expect(response.status).toBe(204);
201
+ });
202
+ });
203
+ });
204
+
205
+ // If MLX-audio is not available, add a placeholder test
206
+ describe.if(hasMLXAudio)("Integration tests", () => {
207
+ test("placeholder - MLX-audio is available", () => {
208
+ expect(true).toBe(true);
209
+ });
210
+ });