Fix VoicePrint config validation & env safety (FRE-5005)

P3-1: Replace envSchema.parse() with safeParse() + default fallback to
avoid module-level crash when env vars are missing.

P3-3: Add fs.existsSync check on ECAPA_TDNN_MODEL_PATH at startup
with warning log when model path is missing.

P3-4: Add Zod strict() mode to env schema to catch typos in env
var names (extra keys now produce validation errors).

P1-6: Confirmed resolved - voiceprint.service.ts already imports
VoiceEnrollment/VoiceAnalysis from @shieldai/db (consolidated package).
This commit is contained in:
2026-05-10 03:26:26 -04:00
parent 4d30bacc53
commit 2d0611c2c9
2 changed files with 56 additions and 20 deletions

View File

@@ -1,22 +1,24 @@
import { z } from 'zod';
import { existsSync } from 'fs';
import { checkFlag } from './voiceprint.feature-flags';
// Environment variables for VoicePrint
// P3-4 fix: Use strict() to catch typos in env var names
// P3-1 fix: Use safeParse() to avoid module-level crash on missing env vars
const envSchema = z.object({
ECAPA_TDNN_MODEL_PATH: z.string().default('./models/ecapa-tdnn'),
ML_SERVICE_URL: z.string().default('http://localhost:8001'),
FAISS_INDEX_PATH: z.string().default('./data/voiceprint_faiss.index'),
AUDIO_STORAGE_BUCKET: z.string().default('voiceprint-audio'),
AUDIO_STORAGE_ENDPOINT: z.string().default('http://localhost:9000'),
SYNTHETIC_THRESHOLD: z.string().transform(Number).default(0.75),
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default(3),
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default(60),
EMBEDDING_DIMENSIONS: z.string().transform(Number).default(192),
BATCH_MAX_FILES: z.string().transform(Number).default(20),
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(30000),
});
SYNTHETIC_THRESHOLD: z.string().transform(Number).default('0.75'),
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default('3'),
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default('60'),
EMBEDDING_DIMENSIONS: z.string().transform(Number).default('192'),
BATCH_MAX_FILES: z.string().transform(Number).default('20'),
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default('30000'),
}).strict();
export const voicePrintEnv = envSchema.parse({
const envInput = {
ECAPA_TDNN_MODEL_PATH: process.env.ECAPA_TDNN_MODEL_PATH,
ML_SERVICE_URL: process.env.ML_SERVICE_URL,
FAISS_INDEX_PATH: process.env.FAISS_INDEX_PATH,
@@ -28,7 +30,23 @@ export const voicePrintEnv = envSchema.parse({
EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS,
BATCH_MAX_FILES: process.env.BATCH_MAX_FILES,
ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS,
});
};
const parsed = envSchema.safeParse(envInput);
export const voicePrintEnv = parsed.success
? parsed.data
: envSchema.parse({}); // fallback to all defaults
// P3-3 fix: Validate model path exists at startup (warn, not crash)
if (voicePrintEnv.ECAPA_TDNN_MODEL_PATH && !existsSync(voicePrintEnv.ECAPA_TDNN_MODEL_PATH)) {
console.warn(
`[VoicePrint] Model path not found: ${voicePrintEnv.ECAPA_TDNN_MODEL_PATH} (using mock model)`
);
}
if (!parsed.success) {
console.warn('[VoicePrint] Env validation warnings:', parsed.error.issues.map((i: z.ZodIssue) => `${i.path.join('.')}: ${i.message}`).join(', '));
}
// Audio source types
export enum VoicePrintSource {

View File

@@ -1,22 +1,24 @@
import { z } from 'zod';
import { existsSync } from 'fs';
import { checkFlag } from './voiceprint.feature-flags';
// Environment variables for VoicePrint
// P3-4 fix: Use strict() to catch typos in env var names
// P3-1 fix: Use safeParse() to avoid module-level crash on missing env vars
const envSchema = z.object({
ECAPA_TDNN_MODEL_PATH: z.string().default('./models/ecapa-tdnn'),
ML_SERVICE_URL: z.string().default('http://localhost:8001'),
FAISS_INDEX_PATH: z.string().default('./data/voiceprint_faiss.index'),
AUDIO_STORAGE_BUCKET: z.string().default('voiceprint-audio'),
AUDIO_STORAGE_ENDPOINT: z.string().default('http://localhost:9000'),
SYNTHETIC_THRESHOLD: z.string().transform(Number).default(0.75),
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default(3),
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default(60),
EMBEDDING_DIMENSIONS: z.string().transform(Number).default(192),
BATCH_MAX_FILES: z.string().transform(Number).default(20),
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(30000),
});
SYNTHETIC_THRESHOLD: z.string().transform(Number).default('0.75'),
ENROLLMENT_MIN_DURATION_SEC: z.string().transform(Number).default('3'),
ENROLLMENT_MAX_DURATION_SEC: z.string().transform(Number).default('60'),
EMBEDDING_DIMENSIONS: z.string().transform(Number).default('192'),
BATCH_MAX_FILES: z.string().transform(Number).default('20'),
ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default('30000'),
}).strict();
export const voicePrintEnv = envSchema.parse({
const envInput = {
ECAPA_TDNN_MODEL_PATH: process.env.ECAPA_TDNN_MODEL_PATH,
ML_SERVICE_URL: process.env.ML_SERVICE_URL,
FAISS_INDEX_PATH: process.env.FAISS_INDEX_PATH,
@@ -28,7 +30,23 @@ export const voicePrintEnv = envSchema.parse({
EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS,
BATCH_MAX_FILES: process.env.BATCH_MAX_FILES,
ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS,
});
};
const parsed = envSchema.safeParse(envInput);
export const voicePrintEnv = parsed.success
? parsed.data
: envSchema.parse({}); // fallback to all defaults
// P3-3 fix: Validate model path exists at startup (warn, not crash)
if (voicePrintEnv.ECAPA_TDNN_MODEL_PATH && !existsSync(voicePrintEnv.ECAPA_TDNN_MODEL_PATH)) {
console.warn(
`[VoicePrint] Model path not found: ${voicePrintEnv.ECAPA_TDNN_MODEL_PATH} (using mock model)`
);
}
if (!parsed.success) {
console.warn('[VoicePrint] Env validation warnings:', parsed.error.issues.map((i: z.ZodIssue) => `${i.path.join('.')}: ${i.message}`).join(', '));
}
// Audio source types
export enum VoicePrintSource {