/** * VoicePrint Service - Legacy Module * * @deprecated This file contains legacy service implementations. * Migrate to the new modular structure: * - Use `import { AnalysisService } from './analysis/AnalysisService'` for analysis * - Use `import { BatchAnalysisService } from './analysis/BatchAnalysisService'` for batch operations * - Use `import { EmbeddingService } from './embedding/EmbeddingService'` for embeddings * - Use `import { VoiceEnrollmentService } from './enrollment/VoiceEnrollmentService'` for enrollment */ import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldai/db'; import { voicePrintEnv, AnalysisJobStatus, DetectionType, ConfidenceLevel, audioPreprocessingConfig, voicePrintFeatureFlags, } from './voiceprint.config'; import { checkFlag } from './voiceprint.feature-flags'; import { createHash } from 'crypto'; import { logger } from './logger'; // Audio preprocessing service export class AudioPreprocessor { /** * Normalize audio to 16kHz mono with VAD and noise reduction. * Returns preprocessing metadata and the processed audio buffer. */ async preprocess( audioBuffer: Buffer, options?: { sourceSampleRate?: number; channels?: number; } ): Promise<{ buffer: Buffer; metadata: { sampleRate: number; channels: number; duration: number; format: string; }; }> { const duration = this.estimateDuration(audioBuffer, options?.sourceSampleRate ?? 44100); if (duration < voicePrintEnv.ENROLLMENT_MIN_DURATION_SEC) { throw new Error( `Audio too short: ${duration.toFixed(1)}s < ${voicePrintEnv.ENROLLMENT_MIN_DURATION_SEC}s minimum` ); } if (duration > voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC) { throw new Error( `Audio too long: ${duration.toFixed(1)}s > ${voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC}s maximum` ); } // TODO: Integrate with Python librosa/torchaudio for actual preprocessing // For MVP, return original buffer with target metadata return { buffer: audioBuffer, metadata: { sampleRate: audioPreprocessingConfig.sampleRate, channels: audioPreprocessingConfig.channels, duration, format: 'wav', }, }; } /** * Apply Voice Activity Detection to remove silence segments. */ async applyVAD(buffer: Buffer): Promise { // TODO: Integrate with Python webrtcvad or silero-vad // For MVP, return original buffer return buffer; } /** * Estimate audio duration from buffer size and sample rate. */ private estimateDuration( buffer: Buffer, sampleRate: number ): number { const bytesPerSample = 2; const channels = 1; const samples = buffer.length / (bytesPerSample * channels); return samples / sampleRate; } } // Voice enrollment service export class VoiceEnrollmentService { /** * Enroll a new voice profile from audio data. */ async enroll( userId: string, name: string, audioBuffer: Buffer ): Promise { const preprocessor = new AudioPreprocessor(); const processed = await preprocessor.preprocess(audioBuffer); const embeddingService = new EmbeddingService(); const embedding = await embeddingService.extract(processed.buffer); const voiceHash = this.computeEmbeddingHash(embedding); const enrollment = await prisma.voiceEnrollment.create({ data: { userId, name, voiceHash, audioMetadata: { ...processed.metadata, embeddingDimensions: embedding.length, enrollmentTimestamp: new Date().toISOString(), }, }, }); // Index in FAISS for similarity search const faissIndex = new FAISSIndex(); await faissIndex.add(enrollment.id, embedding); return enrollment; } /** * List all enrollments for a user. */ async listEnrollments( userId: string, options?: { isActive?: boolean; limit?: number; offset?: number; } ): Promise { return prisma.voiceEnrollment.findMany({ where: { userId, ...(options?.isActive !== undefined && { isActive: options.isActive }), }, orderBy: { createdAt: 'desc' }, take: options?.limit ?? 50, skip: options?.offset ?? 0, }); } /** * Get a single enrollment by ID. */ async getEnrollment( enrollmentId: string, userId: string ): Promise { return prisma.voiceEnrollment.findFirst({ where: { id: enrollmentId, userId, }, }); } /** * Remove (deactivate) an enrollment. */ async removeEnrollment( enrollmentId: string, userId: string ): Promise { const enrollment = await this.getEnrollment(enrollmentId, userId); if (!enrollment) { throw new Error('Enrollment not found'); } const faissIndex = new FAISSIndex(); await faissIndex.remove(enrollmentId); return prisma.voiceEnrollment.update({ where: { id: enrollmentId }, data: { isActive: false }, }); } /** * Search for similar enrollments using FAISS. */ async findSimilar( embedding: number[], topK: number = 5 ): Promise> { const faissIndex = new FAISSIndex(); const results = await faissIndex.search(embedding, topK); const enrollmentIds = results.map((r) => r.id); const enrollments = await prisma.voiceEnrollment.findMany({ where: { id: { in: enrollmentIds } }, }); return results.map((r, i) => ({ enrollment: enrollments[i], similarity: r.similarity, })); } private computeEmbeddingHash(embedding: number[]): string { const hash = createHash('sha256') .update(JSON.stringify(embedding)) .digest('hex'); return `vp_${hash.substring(0, 16)}_${embedding.length}`; } } // Audio analysis service export class AnalysisService { /** * Analyze a single audio file for synthetic voice detection. */ async analyze( userId: string, audioBuffer: Buffer, options?: { enrollmentId?: string; audioUrl?: string; } ): Promise { const preprocessor = new AudioPreprocessor(); const processed = await preprocessor.preprocess(audioBuffer); const audioHash = this.computeAudioHash(audioBuffer); const embeddingService = new EmbeddingService(); const analysisResult = await embeddingService.analyze(processed.buffer); const isSynthetic = analysisResult.confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD; const voiceAnalysis = await prisma.voiceAnalysis.create({ data: { userId, enrollmentId: options?.enrollmentId, audioHash, isSynthetic, confidence: analysisResult.confidence, analysisResult: { ...analysisResult, processedMetadata: processed.metadata, analysisTimestamp: new Date().toISOString(), modelVersion: 'ecapa-tdnn-v1-mock', }, audioUrl: options?.audioUrl ?? '', }, }); return voiceAnalysis; } /** * Get analysis result by ID. */ async getResult( analysisId: string, userId: string ): Promise { return prisma.voiceAnalysis.findFirst({ where: { id: analysisId, userId, }, }); } /** * Get analysis history for a user. */ async getHistory( userId: string, options?: { limit?: number; offset?: number; isSynthetic?: boolean; } ): Promise { return prisma.voiceAnalysis.findMany({ where: { userId, ...(options?.isSynthetic !== undefined && { isSynthetic: options.isSynthetic }), }, orderBy: { createdAt: 'desc' }, take: options?.limit ?? 50, skip: options?.offset ?? 0, }); } private computeAudioHash(buffer: Buffer): string { const hash = createHash('sha256') .update(buffer) .digest('hex'); return `audio_${hash.substring(0, 16)}`; } } // Batch analysis service export class BatchAnalysisService { /** * Analyze multiple audio files in a batch. */ async analyzeBatch( userId: string, files: Array<{ name: string; buffer: Buffer; audioUrl?: string; }>, options?: { enrollmentId?: string; } ): Promise<{ jobId: string; results: VoiceAnalysis[]; summary: { total: number; synthetic: number; natural: number; failed: number; }; }> { if (files.length > voicePrintEnv.BATCH_MAX_FILES) { throw new Error( `Batch too large: ${files.length} > ${voicePrintEnv.BATCH_MAX_FILES} max` ); } const jobId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; logger.info('Starting batch analysis', { jobId, userId, fileCount: files.length }); const analysisService = new AnalysisService(); const results: VoiceAnalysis[] = []; let synthetic = 0; let natural = 0; let failed = 0; // Process with concurrency control const concurrencyLimit = 5; for (let i = 0; i < files.length; i += concurrencyLimit) { const chunk = files.slice(i, i + concurrencyLimit); const promises = chunk.map(async (file) => { try { const result = await analysisService.analyze(userId, file.buffer, { enrollmentId: options?.enrollmentId, audioUrl: file.audioUrl, }); return { success: true as const, result, name: file.name }; } catch (error) { logger.error('Batch analysis failed for file', { fileName: file.name, jobId, error }); return { success: false as const, error: error instanceof Error ? error.message : 'Unknown error', name: file.name }; } }); const outcomes = await Promise.allSettled(promises); for (const outcome of outcomes) { if (outcome.status === 'fulfilled') { if (outcome.value.success) { results.push(outcome.value.result); if (outcome.value.result.isSynthetic) { synthetic++; } else { natural++; } } else { failed++; } } } } // Persist batch jobId to database await prisma.$transaction([ prisma.$executeRawUnsafe('INSERT INTO batch_jobs (id, user_id, total_files, status, created_at) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (id) DO NOTHING', jobId, userId, files.length, 'completed'), ...results.map(result => prisma.$executeRawUnsafe('UPDATE voice_analysis SET batch_job_id = $1 WHERE id = $2', jobId, result.id) ) ]).catch(err => { logger.warn('Failed to persist batch jobId', { jobId, error: err instanceof Error ? err.message : String(err) }); }); logger.info('Batch analysis completed', { jobId, total: files.length, synthetic, natural, failed }); return { jobId, results, summary: { total: files.length, synthetic, natural, failed, }, }; } } // Deprecated: Use embedding/EmbeddingService.ts instead // This class is kept for backward compatibility but delegates to the canonical service /** * @deprecated Use `import { EmbeddingService } from './embedding/EmbeddingService'` instead */ export class EmbeddingService { private initialized = false; /** * Initialize the ECAPA-TDNN model. * @deprecated Use the canonical EmbeddingService from embedding/EmbeddingService.ts */ async initialize(): Promise { if (this.initialized) return; this.initialized = true; logger.warn('Deprecated EmbeddingService initialized - migrate to embedding/EmbeddingService.ts'); } /** * Extract voice embedding from audio. * @deprecated Use the canonical EmbeddingService from embedding/EmbeddingService.ts */ async extract(audioBuffer: Buffer): Promise { await this.initialize(); // Delegate to canonical implementation const canonicalService = new CanonicalEmbeddingService(); const result = await canonicalService.extract(audioBuffer); return result.vector; } /** * Run full analysis: embedding + synthetic detection. * @deprecated Use AnalysisService from analysis/AnalysisService.ts instead */ async analyze(audioBuffer: Buffer): Promise<{ confidence: number; detectionType: DetectionType; features: Record; embedding: number[]; }> { const embeddingService = new CanonicalEmbeddingService(); const result = await embeddingService.analyze(audioBuffer); return { confidence: result.confidence, detectionType: result.detectionType, features: result.features, embedding: result.vector, }; } } // Canonical embedding service - single source of truth for embedding logic class CanonicalEmbeddingService { private initialized = false; async initialize(): Promise { if (this.initialized) return; this.initialized = true; logger.info('Canonical EmbeddingService initialized', { modelVersion: 'ecapa-tdnn-v1-mock' }); } async extract(audioBuffer: Buffer): Promise<{ vector: number[]; dimension: number }> { await this.initialize(); // Use the same mock generation as embedding/EmbeddingService.ts for consistency const dims = voicePrintEnv.EMBEDDING_DIMENSIONS; let hash = 0; const sampleSize = Math.min(audioBuffer.length, 1024); for (let i = 0; i < sampleSize; i += 4) { hash = ((hash << 5) - hash + audioBuffer.readInt32LE(i)) | 0; } const seed = Math.abs(hash); const rng = this.createRNG(seed); const vector: number[] = []; for (let i = 0; i < dims; i++) { const u1 = rng(); const u2 = rng(); const gauss = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); vector.push(parseFloat(gauss.toFixed(6))); } const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0)); const normalized = vector.map((v) => parseFloat((v / norm).toFixed(6))); return { vector: normalized, dimension: dims }; } async analyze(audioBuffer: Buffer): Promise<{ confidence: number; detectionType: DetectionType; features: Record; vector: number[]; }> { const { vector } = await this.extract(audioBuffer); // Heuristic for synthetic detection const meanAmplitude = audioBuffer.reduce((s, v) => s + v, 0) / audioBuffer.length / 255; const embeddingStdDev = Math.sqrt( vector.reduce((s, v) => s + (v - vector.reduce((a, b) => a + b) / vector.length) ** 2, 0) / vector.length ) || 0; const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2; const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2); const confidence = Math.min(1.0, amplitudeScore * 0.3 + embeddingScore * 0.4 + Math.random() * 0.3); const detectionType = confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD ? DetectionType.SYNTHETIC_VOICE : DetectionType.NATURAL; const zeroCrossings = audioBuffer.reduce((count, v, i, arr) => { return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count; }, 0); const features = { mean_amplitude: meanAmplitude, zero_crossing_rate: zeroCrossings / audioBuffer.length, embedding_energy: vector.reduce((s, v) => s + v * v, 0), embedding_entropy: this.calculateEntropy(vector), }; return { confidence, detectionType, features, vector }; } private createRNG(seed: number): () => number { return () => { seed = (seed * 1664525 + 1013904223) & 0xffffffff; return (seed >>> 0) / 0xffffffff; }; } private calculateEntropy(values: number[]): number { const bins = 20; const histogram = new Array(bins).fill(0); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; for (const v of values) { const bin = Math.min(bins - 1, Math.floor(((v - min) / range) * bins)); histogram[bin]++; } let entropy = 0; const total = values.length; for (const count of histogram) { if (count > 0) { const p = count / total; entropy -= p * Math.log2(p); } } return entropy; } } // FAISS index wrapper for voice fingerprint matching export class FAISSIndex { private indexPath: string; private initialized = false; constructor(path?: string) { this.indexPath = path ?? voicePrintEnv.FAISS_INDEX_PATH; } /** * Initialize or load the FAISS index. */ async initialize(): Promise { if (this.initialized) return; // TODO: Load FAISS index from disk // const faiss = require('faiss-node'); // this.index = faiss.readIndex(this.indexPath); this.initialized = true; logger.info('FAISS index initialized', { indexPath: this.indexPath }); } /** * Add an enrollment embedding to the index. */ async add(enrollmentId: string, embedding: number[]): Promise { await this.initialize(); // TODO: Add to FAISS index // this.index.add([embedding]); // Store mapping: enrollmentId -> index position logger.info('Added enrollment to FAISS index', { enrollmentId, embeddingDimensions: embedding.length }); } /** * Remove an enrollment from the index. */ async remove(enrollmentId: string): Promise { await this.initialize(); // TODO: Remove from FAISS index logger.info('Removed enrollment from FAISS index', { enrollmentId }); } /** * Search for similar voice embeddings. */ async search( embedding: number[], topK: number = 5 ): Promise> { await this.initialize(); // TODO: Query FAISS index // const [distances, indices] = this.index.search([embedding], topK); // Map indices back to enrollment IDs // Mock: return empty results return []; } /** * Save the index to disk. */ async save(): Promise { await this.initialize(); // TODO: Write FAISS index to disk logger.info('FAISS index saved', { indexPath: this.indexPath }); } } // Export classes only - use dependency injection for instantiation // Deprecated singleton exports kept for backward compatibility only /** @deprecated Use `new AudioPreprocessor()` instead */ export const audioPreprocessor = new AudioPreprocessor(); /** @deprecated Use `new VoiceEnrollmentService()` instead */ export const voiceEnrollmentService = new VoiceEnrollmentService(); /** @deprecated Use `new AnalysisService()` instead */ export const analysisService = new AnalysisService(); /** @deprecated Use `new BatchAnalysisService()` instead */ export const batchAnalysisService = new BatchAnalysisService(); /** @deprecated Use `new EmbeddingService()` instead */ export const embeddingService = new EmbeddingService();