diff --git a/.gitignore b/.gitignore index 0c49ace..238b97e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist *.log .DS_Store load-tests/voiceprint/results/ +.turbo diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 11a5472..142c0ff 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -52,6 +52,25 @@ enum UserRole { support } +enum DetectionVerdict { + NATURAL + SYNTHETIC + UNCERTAIN +} + +enum AnalysisType { + SYNTHETIC_DETECTION + VOICE_MATCH + BATCH +} + +enum AnalysisJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + model Account { id String @id @default(uuid()) userId String @@ -337,6 +356,44 @@ model VoiceAnalysis { @@index([audioHash]) } +model AnalysisJob { + id String @id @default(uuid()) + userId String + analysisType AnalysisType + audioFilePath String + status AnalysisJobStatus + errorMessage String? + completedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + result AnalysisResult? + + @@index([userId]) + @@index([status]) + @@index([createdAt]) +} + +model AnalysisResult { + id String @id @default(uuid()) + analysisJobId String + syntheticScore Float + verdict DetectionVerdict + confidence Float + processingTimeMs Int + matchedEnrollmentId String? + matchedSimilarity Float? + modelVersion String? + + analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id]) + + createdAt DateTime @default(now()) + + @@index([analysisJobId]) + @@index([syntheticScore]) + @@index([verdict]) +} + // ============================================ // SpamShield Models (Spam Detection) // ============================================ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f3da4e..5d4f78d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,9 +490,15 @@ importers: '@shieldai/types': specifier: workspace:* version: link:../../packages/types + '@types/uuid': + specifier: ^11.0.0 + version: 11.0.0 node-cache: specifier: ^5.1.2 version: 5.1.2 + uuid: + specifier: ^14.0.0 + version: 14.0.0 devDependencies: '@vitest/coverage-v8': specifier: ^4.1.5 @@ -2748,6 +2754,10 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -5552,6 +5562,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -8450,6 +8464,10 @@ snapshots: '@types/tough-cookie@4.0.5': optional: true + '@types/uuid@11.0.0': + dependencies: + uuid: 14.0.0 + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -11809,6 +11827,8 @@ snapshots: uuid@10.0.0: {} + uuid@14.0.0: {} + uuid@8.3.2: {} uuid@9.0.1: {} diff --git a/services/voiceprint/package.json b/services/voiceprint/package.json index 001334d..c3f71af 100644 --- a/services/voiceprint/package.json +++ b/services/voiceprint/package.json @@ -10,14 +10,16 @@ "lint": "eslint src/" }, "dependencies": { + "@shieldai/correlation": "workspace:*", "@shieldai/db": "workspace:*", "@shieldai/types": "workspace:*", - "@shieldai/correlation": "workspace:*", - "node-cache": "^5.1.2" + "@types/uuid": "^11.0.0", + "node-cache": "^5.1.2", + "uuid": "^14.0.0" }, "devDependencies": { - "vitest": "^4.1.5", - "@vitest/coverage-v8": "^4.1.5" + "@vitest/coverage-v8": "^4.1.5", + "vitest": "^4.1.5" }, "exports": { ".": "./src/index.ts" diff --git a/services/voiceprint/src/analysis/BatchAnalysisService.ts b/services/voiceprint/src/analysis/BatchAnalysisService.ts index b4a4c7b..17a74a0 100644 --- a/services/voiceprint/src/analysis/BatchAnalysisService.ts +++ b/services/voiceprint/src/analysis/BatchAnalysisService.ts @@ -6,9 +6,11 @@ import { AnalysisType, AnalysisResultOutput, } from "@shieldai/types"; +import { logger } from "../logger"; export class BatchAnalysisService { private analysisService: AnalysisService; + private readonly maxConcurrency = 5; constructor() { this.analysisService = new AnalysisService(); @@ -19,43 +21,56 @@ export class BatchAnalysisService { userId: string ): Promise { const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + logger.info("Starting batch analysis", { batchId, userId, totalFiles: input.audioBuffers.length }); const results: AnalysisResultOutput[] = []; const errors: Array<{ name: string; error: string }> = []; - for (const audioInput of input.audioBuffers) { - try { - const result = await this.analysisService.analyze( - { - audioBuffer: audioInput.buffer, - sampleRate: audioInput.sampleRate, - analysisType: input.analysisType || AnalysisType.SYNTHETIC_DETECTION, - }, - userId - ); - results.push(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Analysis failed"; - errors.push({ name: audioInput.name, error: message }); + const processWithConcurrency = async (limit: number) => { + for (let i = 0; i < input.audioBuffers.length; i += limit) { + const chunk = input.audioBuffers.slice(i, i + limit); + + const promises = chunk.map(async (audioInput: { name: string; buffer: Buffer; sampleRate?: number }) => { + try { + const result = await this.analysisService.analyze( + { + audioBuffer: audioInput.buffer, + sampleRate: audioInput.sampleRate, + analysisType: input.analysisType || AnalysisType.SYNTHETIC_DETECTION, + }, + userId + ); + return { success: true, result, name: audioInput.name }; + } catch (err) { + const message = err instanceof Error ? err.message : "Analysis failed"; + return { success: false, error: message, name: audioInput.name }; + } + }); + + const outcomes = await Promise.allSettled(promises); + + for (const outcome of outcomes) { + if (outcome.status === 'fulfilled') { + if (outcome.value.success && outcome.value.result) { + results.push(outcome.value.result); + } else if (!outcome.value.success && outcome.value.name) { + errors.push({ name: outcome.value.name, error: outcome.value.error || "Analysis failed" }); + } + } + } } - } + }; - const batchJob = await prisma.analysisJob.create({ - data: { - userId, - analysisType: AnalysisType.BATCH, - audioFilePath: `voiceprint/${userId}/${batchId}`, - status: errors.length === input.audioBuffers.length - ? AnalysisJobStatus.FAILED - : AnalysisJobStatus.COMPLETED, - errorMessage: - errors.length > 0 ? `${errors.length} of ${input.audioBuffers.length} files failed` : undefined, - completedAt: new Date(), - }, + await processWithConcurrency(this.maxConcurrency); + + logger.info("Batch analysis completed", { + batchId, + successfulResults: results.length, + failedCount: errors.length }); return { batchId, - jobId: batchJob.id, + jobId: `batch_${batchId}`, totalFiles: input.audioBuffers.length, successfulResults: results.length, failedCount: errors.length, diff --git a/services/voiceprint/src/embedding/EmbeddingService.ts b/services/voiceprint/src/embedding/EmbeddingService.ts index 6946276..3c2a6c6 100644 --- a/services/voiceprint/src/embedding/EmbeddingService.ts +++ b/services/voiceprint/src/embedding/EmbeddingService.ts @@ -1,10 +1,14 @@ import { spawn } from "child_process"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../logger"; const EMBEDDING_DIM = 192; const MODEL_VERSION = "ecapa-tdnn-0.1.0-mock"; export class EmbeddingService { private mlServiceUrl: string; + private readonly maxRetries = 3; + private readonly retryDelay = 1000; constructor() { this.mlServiceUrl = process.env.VOICEPRINT_ML_URL || "http://localhost:8001"; @@ -14,20 +18,34 @@ export class EmbeddingService { const mlAvailable = await this.checkMLService(); if (mlAvailable) { + logger.info("Using ML service for embedding extraction", { mlUrl: this.mlServiceUrl }); return this.extractViaML(audioBuffer); } - return this.extractMock(audioBuffer); + logger.info("Using mock embedding generation", { audioBufferLength: audioBuffer.length }); + return this.generateMockFromBuffer(audioBuffer); } async classify(embedding: number[]): Promise { const mlAvailable = await this.checkMLService(); if (mlAvailable) { + logger.info("Using ML service for classification", { embeddingLength: embedding.length }); return this.classifyViaML(embedding); } - return this.classifyMock(embedding); + logger.info("Using mock classification", { embeddingLength: embedding.length }); + const mean = embedding.reduce((s, v) => s + v, 0) / embedding.length; + const variance = embedding.reduce((s, v) => s + (v - mean) ** 2, 0) / embedding.length; + const stdDev = Math.sqrt(variance); + + const syntheticIndicators = [ + stdDev < 0.1 ? 0.8 : 0.2, + Math.abs(mean) > 0.5 ? 0.7 : 0.3, + this.hasArtifacts(embedding) ? 0.9 : 0.1, + ]; + + return syntheticIndicators.reduce((s, v) => s + v, 0) / syntheticIndicators.length; } getModelVersion(): string { @@ -105,26 +123,29 @@ except: }); } - private async extractMock(audioBuffer: Buffer): Promise { - return this.generateMockFromBuffer(audioBuffer); - } + private hasArtifacts(embedding: number[]): boolean { + const window = 16; + let artifactCount = 0; - private async classifyMock(embedding: number[]): Promise { - const mean = embedding.reduce((s, v) => s + v, 0) / embedding.length; - const variance = embedding.reduce((s, v) => s + (v - mean) ** 2, 0) / embedding.length; - const stdDev = Math.sqrt(variance); + for (let i = 0; i < embedding.length - window; i += window) { + const slice = embedding.slice(i, i + window); + const localMean = slice.reduce((s, v) => s + v, 0) / slice.length; + const localVar = slice.reduce((s, v) => s + (v - localMean) ** 2, 0) / slice.length; - const syntheticIndicators = [ - stdDev < 0.1 ? 0.8 : 0.2, - Math.abs(mean) > 0.5 ? 0.7 : 0.3, - this.hasArtifacts(embedding) ? 0.9 : 0.1, - ]; + if (localVar < 0.001) artifactCount++; + } - return syntheticIndicators.reduce((s, v) => s + v, 0) / syntheticIndicators.length; + return artifactCount > embedding.length / window / 3; } private generateMockFromBuffer(audioBuffer: Buffer): EmbeddingOutput { - const seed = this.computeSeed(audioBuffer); + 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[] = []; @@ -141,22 +162,8 @@ except: return { vector: normalized, dimension: EMBEDDING_DIM }; } - private hasArtifacts(embedding: number[]): boolean { - const window = 16; - let artifactCount = 0; - - for (let i = 0; i < embedding.length - window; i += window) { - const slice = embedding.slice(i, i + window); - const localMean = slice.reduce((s, v) => s + v, 0) / slice.length; - const localVar = slice.reduce((s, v) => s + (v - localMean) ** 2, 0) / slice.length; - - if (localVar < 0.001) artifactCount++; - } - - return artifactCount > embedding.length / window / 3; - } - private async checkMLService(): Promise { + logger.info("Checking ML service availability", { mlUrl: this.mlServiceUrl }); return new Promise((resolve) => { const proc = spawn("python3", [ "-c", @@ -173,15 +180,6 @@ except: }); } - private computeSeed(buffer: Buffer): number { - let hash = 0; - const sampleSize = Math.min(buffer.length, 1024); - for (let i = 0; i < sampleSize; i += 4) { - hash = ((hash << 5) - hash + buffer.readInt32LE(i)) | 0; - } - return Math.abs(hash); - } - private createRNG(seed: number): () => number { return () => { seed = (seed * 1664525 + 1013904223) & 0xffffffff; diff --git a/services/voiceprint/src/enrollment/VoiceEnrollmentService.ts b/services/voiceprint/src/enrollment/VoiceEnrollmentService.ts index 1b3b218..35d4752 100644 --- a/services/voiceprint/src/enrollment/VoiceEnrollmentService.ts +++ b/services/voiceprint/src/enrollment/VoiceEnrollmentService.ts @@ -23,11 +23,13 @@ export class VoiceEnrollmentService { const enrollment = await prisma.voiceEnrollment.create({ data: { userId, - label: input.label, - embeddingVector: embedding.vector, - embeddingDim: embedding.dimension, - sampleRate: preprocessed.sampleRate, - durationSec: preprocessed.durationSec, + name: input.label, + voiceHash: this.computeVoiceHash(embedding.vector), + audioMetadata: { + sampleRate: preprocessed.sampleRate, + durationSec: preprocessed.durationSec, + embeddingDim: embedding.dimension, + }, }, }); @@ -35,10 +37,10 @@ export class VoiceEnrollmentService { return { id: enrollment.id, - label: enrollment.label, - embeddingDim: enrollment.embeddingDim, - sampleRate: enrollment.sampleRate, - durationSec: enrollment.durationSec, + label: enrollment.name, + embeddingDim: preprocessed.sampleRate, + sampleRate: preprocessed.sampleRate, + durationSec: preprocessed.durationSec, createdAt: enrollment.createdAt, }; } diff --git a/services/voiceprint/src/logger.ts b/services/voiceprint/src/logger.ts new file mode 100644 index 0000000..34c4118 --- /dev/null +++ b/services/voiceprint/src/logger.ts @@ -0,0 +1,36 @@ +import { FastifyLoggerOptions } from 'fastify'; + +export interface Logger { + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; + debug(message: string, context?: Record): void; +} + +export class ConsoleLogger implements Logger { + info(message: string, context?: Record): void { + const timestamp = new Date().toISOString(); + const logContext = context ? ` ${JSON.stringify(context)}` : ''; + console.log(`[${timestamp}] [INFO] ${message}${logContext}`); + } + + warn(message: string, context?: Record): void { + const timestamp = new Date().toISOString(); + const logContext = context ? ` ${JSON.stringify(context)}` : ''; + console.warn(`[${timestamp}] [WARN] ${message}${logContext}`); + } + + error(message: string, context?: Record): void { + const timestamp = new Date().toISOString(); + const logContext = context ? ` ${JSON.stringify(context)}` : ''; + console.error(`[${timestamp}] [ERROR] ${message}${logContext}`); + } + + debug(message: string, context?: Record): void { + const timestamp = new Date().toISOString(); + const logContext = context ? ` ${JSON.stringify(context)}` : ''; + console.debug(`[${timestamp}] [DEBUG] ${message}${logContext}`); + } +} + +export const logger = new ConsoleLogger(); diff --git a/services/voiceprint/src/voiceprint.service.ts b/services/voiceprint/src/voiceprint.service.ts index 9eb55b8..58f11da 100644 --- a/services/voiceprint/src/voiceprint.service.ts +++ b/services/voiceprint/src/voiceprint.service.ts @@ -8,6 +8,7 @@ import { voicePrintFeatureFlags, } from './voiceprint.config'; import { checkFlag } from './voiceprint.feature-flags'; +import { createHash } from 'crypto'; // Audio preprocessing service export class AudioPreprocessor { @@ -197,12 +198,10 @@ export class VoiceEnrollmentService { } private computeEmbeddingHash(embedding: number[]): string { - let hash = 0; - for (let i = 0; i < embedding.length; i++) { - hash = ((hash << 5) - hash) + embedding[i]; - hash |= 0; - } - return `vp_${Math.abs(hash).toString(16)}_${embedding.length}`; + const hash = createHash('sha256') + .update(JSON.stringify(embedding)) + .digest('hex'); + return `vp_${hash.substring(0, 16)}_${embedding.length}`; } } @@ -287,13 +286,10 @@ export class AnalysisService { } private computeAudioHash(buffer: Buffer): string { - let hash = 0; - const sampleSize = Math.min(buffer.length, 1024); - for (let i = 0; i < sampleSize; i += 8) { - hash = ((hash << 5) - hash) + buffer.readUInt8(i); - hash |= 0; - } - return `audio_${Math.abs(hash).toString(16)}`; + const hash = createHash('sha256') + .update(buffer) + .digest('hex'); + return `audio_${hash.substring(0, 16)}`; } }