@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.
- package/.claude/commands/speckit.analyze.md +184 -0
- package/.claude/commands/speckit.checklist.md +294 -0
- package/.claude/commands/speckit.clarify.md +181 -0
- package/.claude/commands/speckit.constitution.md +82 -0
- package/.claude/commands/speckit.implement.md +135 -0
- package/.claude/commands/speckit.plan.md +89 -0
- package/.claude/commands/speckit.specify.md +258 -0
- package/.claude/commands/speckit.tasks.md +137 -0
- package/.claude/commands/speckit.taskstoissues.md +30 -0
- package/.claude/settings.local.json +23 -0
- package/.codanna/settings.toml +384 -0
- package/.env.development +18 -0
- package/.env.example +30 -0
- package/.github/codeql/config.yml +13 -0
- package/.github/codeql.yml +30 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +308 -0
- package/.specify/memory/constitution.md +223 -0
- package/.specify/scripts/bash/check-prerequisites.sh +166 -0
- package/.specify/scripts/bash/common.sh +156 -0
- package/.specify/scripts/bash/create-new-feature.sh +297 -0
- package/.specify/scripts/bash/setup-plan.sh +61 -0
- package/.specify/scripts/bash/update-agent-context.sh +799 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +40 -0
- package/.specify/templates/plan-template.md +106 -0
- package/.specify/templates/spec-template.md +115 -0
- package/.specify/templates/tasks-template.md +261 -0
- package/AGENTPERSONALITIES.md +233 -0
- package/ATTRIBUTION.md +70 -0
- package/CHANGELOG.md +90 -0
- package/CLAUDE.md +50 -0
- package/Formula/madeinoz-voice-server.rb +106 -0
- package/README.md +451 -0
- package/bun.lock +212 -0
- package/cliff.toml +67 -0
- package/docs/KOKORO_VOICES.md +152 -0
- package/docs/MIGRATION.md +267 -0
- package/docs/VOICE_EXAMPLES.md +283 -0
- package/docs/VOICE_GUIDE.md +227 -0
- package/docs/VOICE_QUICK_REF.md +157 -0
- package/docs/agent-voices.md +114 -0
- package/docs/api.md +336 -0
- package/docs/assets/voice-server-architecture.png +0 -0
- package/docs/assets/voice-server-header.png +0 -0
- package/docs/assets/voice-server-pack-logo.png +0 -0
- package/docs/index.md +60 -0
- package/eslint.config.js +42 -0
- package/mkdocs.yml +55 -0
- package/package.json +28 -0
- package/reports/MLX_AUDIO_EVALUATION.md +302 -0
- package/reports/agent/2026-02-06-20-51-mlx-audio-qwen-tts-investigation.md +613 -0
- package/reports/agent/2026-02-06-Qwen3-TTS-API-Specification.md +446 -0
- package/reports/agent/2026-02-07-python-backend-removal-plan.md +790 -0
- package/scripts/generate-reference.ts +139 -0
- package/specs/001-qwen-tts/checklists/requirements.md +50 -0
- package/specs/001-qwen-tts/contracts/api.yaml +305 -0
- package/specs/001-qwen-tts/data-model.md +197 -0
- package/specs/001-qwen-tts/plan.md +236 -0
- package/specs/001-qwen-tts/quickstart.md +306 -0
- package/specs/001-qwen-tts/research.md +194 -0
- package/specs/001-qwen-tts/spec.md +135 -0
- package/specs/001-qwen-tts/tasks.md +305 -0
- package/src/ts/constants/KOKORO_VOICES.ts +141 -0
- package/src/ts/middleware/cors.ts +153 -0
- package/src/ts/middleware/rate-limiter.ts +200 -0
- package/src/ts/models/health.ts +45 -0
- package/src/ts/models/notification.ts +69 -0
- package/src/ts/models/pronunciation.ts +39 -0
- package/src/ts/models/tts.ts +54 -0
- package/src/ts/models/voice-config.ts +82 -0
- package/src/ts/server.ts +460 -0
- package/src/ts/services/mlx-tts-client.ts +337 -0
- package/src/ts/services/pronunciation.ts +209 -0
- package/src/ts/services/prosody-translator.ts +130 -0
- package/src/ts/services/voice-loader.ts +214 -0
- package/src/ts/utils/logger.ts +144 -0
- package/src/ts/utils/text-sanitizer.ts +118 -0
- package/tests/integration/api.test.ts +210 -0
- package/tests/mocks/index.ts +152 -0
- package/tests/ts/server.test.ts +11 -0
- package/tests/unit/middleware/cors.test.ts +146 -0
- package/tests/unit/models/validation.test.ts +332 -0
- package/tests/unit/services/pronunciation.test.ts +171 -0
- package/tests/unit/services/prosody-translator.test.ts +142 -0
- 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
|
+
});
|