Auto-commit 2026-04-29 16:31
This commit is contained in:
55
apps/api/src/config/api.config.ts
Normal file
55
apps/api/src/config/api.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().transform(Number).default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
|
||||
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
});
|
||||
|
||||
export const apiEnv = envSchema.parse({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PORT: process.env.PORT,
|
||||
HOST: process.env.HOST,
|
||||
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
|
||||
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
});
|
||||
|
||||
// Rate limit configuration by tier
|
||||
export const rateLimitConfig = {
|
||||
basic: {
|
||||
windowMs: 60000, // 1 minute
|
||||
maxRequests: 100,
|
||||
},
|
||||
plus: {
|
||||
windowMs: 60000,
|
||||
maxRequests: 500,
|
||||
},
|
||||
premium: {
|
||||
windowMs: 60000,
|
||||
maxRequests: 2000,
|
||||
},
|
||||
};
|
||||
|
||||
// API versioning configuration
|
||||
export const apiVersioning = {
|
||||
defaultVersion: '1',
|
||||
headerName: 'X-API-Version',
|
||||
queryParam: 'api-version',
|
||||
};
|
||||
|
||||
// Logging configuration
|
||||
export const loggingConfig = {
|
||||
level: apiEnv.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: apiEnv.NODE_ENV === 'development' ? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: true,
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { authMiddleware, AuthRequest } from './auth.middleware';
|
||||
import { voiceprintRoutes } from './voiceprint.routes';
|
||||
|
||||
export async function routes(fastify: FastifyInstance) {
|
||||
// Authenticated routes group
|
||||
@@ -112,4 +113,12 @@ export async function routes(fastify: FastifyInstance) {
|
||||
},
|
||||
{ prefix: '/api/v1/services' }
|
||||
);
|
||||
|
||||
// VoicePrint service routes
|
||||
fastify.register(
|
||||
async (voiceprintRouter) => {
|
||||
await voiceprintRoutes(voiceprintRouter);
|
||||
},
|
||||
{ prefix: '/voiceprint' }
|
||||
);
|
||||
}
|
||||
|
||||
257
apps/api/src/routes/voiceprint.routes.ts
Normal file
257
apps/api/src/routes/voiceprint.routes.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import {
|
||||
voiceEnrollmentService,
|
||||
analysisService,
|
||||
batchAnalysisService,
|
||||
voicePrintEnv,
|
||||
AnalysisJobStatus,
|
||||
} from '../services/voiceprint';
|
||||
|
||||
export async function voiceprintRoutes(fastify: FastifyInstance) {
|
||||
// Enroll a new voice profile
|
||||
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
name: string;
|
||||
audio: Buffer;
|
||||
};
|
||||
|
||||
if (!body.name || !body.audio) {
|
||||
return reply.code(400).send({ error: 'name and audio are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const enrollment = await voiceEnrollmentService.enroll(
|
||||
userId,
|
||||
body.name,
|
||||
body.audio
|
||||
);
|
||||
return reply.code(201).send({
|
||||
enrollment: {
|
||||
id: enrollment.id,
|
||||
name: enrollment.name,
|
||||
isActive: enrollment.isActive,
|
||||
createdAt: enrollment.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Enrollment failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// List user's voice enrollments
|
||||
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const isActive = request.query as { isActive?: string };
|
||||
const limit = request.query as { limit?: string };
|
||||
const offset = request.query as { offset?: string };
|
||||
|
||||
const enrollments = await voiceEnrollmentService.listEnrollments(userId, {
|
||||
isActive: isActive.isActive !== undefined
|
||||
? isActive.isActive === 'true'
|
||||
: undefined,
|
||||
limit: limit.limit ? parseInt(limit.limit, 10) : undefined,
|
||||
offset: offset.offset ? parseInt(offset.offset, 10) : undefined,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
enrollments: enrollments.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
isActive: e.isActive,
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Remove an enrollment
|
||||
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const enrollmentId = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const enrollment = await voiceEnrollmentService.removeEnrollment(
|
||||
enrollmentId,
|
||||
userId
|
||||
);
|
||||
return reply.send({
|
||||
enrollment: {
|
||||
id: enrollment.id,
|
||||
name: enrollment.name,
|
||||
isActive: enrollment.isActive,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Removal failed';
|
||||
return reply.code(404).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze a single audio file
|
||||
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
audio: Buffer;
|
||||
enrollmentId?: string;
|
||||
audioUrl?: string;
|
||||
};
|
||||
|
||||
if (!body.audio) {
|
||||
return reply.code(400).send({ error: 'audio is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await analysisService.analyze(userId, body.audio, {
|
||||
enrollmentId: body.enrollmentId,
|
||||
audioUrl: body.audioUrl,
|
||||
});
|
||||
return reply.code(201).send({
|
||||
analysis: {
|
||||
id: result.id,
|
||||
isSynthetic: result.isSynthetic,
|
||||
confidence: result.confidence,
|
||||
analysisResult: result.analysisResult,
|
||||
createdAt: result.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Analysis failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get analysis result by ID
|
||||
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const analysisId = (request.params as { id: string }).id;
|
||||
const result = await analysisService.getResult(analysisId, userId);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({ error: 'Analysis not found' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
analysis: {
|
||||
id: result.id,
|
||||
isSynthetic: result.isSynthetic,
|
||||
confidence: result.confidence,
|
||||
analysisResult: result.analysisResult,
|
||||
createdAt: result.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Get analysis history
|
||||
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const query = request.query as {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
isSynthetic?: string;
|
||||
};
|
||||
|
||||
const results = await analysisService.getHistory(userId, {
|
||||
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
||||
offset: query.offset ? parseInt(query.offset, 10) : undefined,
|
||||
isSynthetic: query.isSynthetic !== undefined
|
||||
? query.isSynthetic === 'true'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
analyses: results.map((r) => ({
|
||||
id: r.id,
|
||||
isSynthetic: r.isSynthetic,
|
||||
confidence: r.confidence,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Batch analyze multiple audio files
|
||||
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
const body = request.body as {
|
||||
files: Array<{
|
||||
name: string;
|
||||
audio: Buffer;
|
||||
audioUrl?: string;
|
||||
}>;
|
||||
enrollmentId?: string;
|
||||
};
|
||||
|
||||
if (!body.files || body.files.length === 0) {
|
||||
return reply.code(400).send({ error: 'files array is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await batchAnalysisService.analyzeBatch(
|
||||
userId,
|
||||
body.files.map((f) => ({
|
||||
name: f.name,
|
||||
buffer: f.audio,
|
||||
audioUrl: f.audioUrl,
|
||||
})),
|
||||
{
|
||||
enrollmentId: body.enrollmentId,
|
||||
}
|
||||
);
|
||||
|
||||
return reply.code(201).send({
|
||||
jobId: result.jobId,
|
||||
results: result.results.map((r) => ({
|
||||
id: r.id,
|
||||
isSynthetic: r.isSynthetic,
|
||||
confidence: r.confidence,
|
||||
})),
|
||||
summary: result.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Batch analysis failed';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
21
apps/api/src/services/spamshield/index.ts
Normal file
21
apps/api/src/services/spamshield/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Config
|
||||
export {
|
||||
spamShieldEnv,
|
||||
SpamLayer,
|
||||
SpamDecision,
|
||||
ConfidenceLevel,
|
||||
spamFeatureFlags,
|
||||
spamRateLimits,
|
||||
} from './spamshield.config';
|
||||
|
||||
// Services
|
||||
export {
|
||||
NumberReputationService,
|
||||
SMSClassifierService,
|
||||
CallAnalysisService,
|
||||
SpamFeedbackService,
|
||||
numberReputationService,
|
||||
smsClassifierService,
|
||||
callAnalysisService,
|
||||
spamFeedbackService,
|
||||
} from './spamshield.service';
|
||||
71
apps/api/src/services/spamshield/spamshield.config.ts
Normal file
71
apps/api/src/services/spamshield/spamshield.config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables for SpamShield
|
||||
const envSchema = z.object({
|
||||
HIYA_API_KEY: z.string(),
|
||||
HIYA_API_URL: z.string().default('https://api.hiya.com/v1'),
|
||||
TRUECALLER_API_KEY: z.string().optional(),
|
||||
BERT_MODEL_PATH: z.string().default('./models/spam-classifier'),
|
||||
SPAM_THRESHOLD_AUTO_BLOCK: z.string().transform(Number).default(0.85),
|
||||
SPAM_THRESHOLD_FLAG: z.string().transform(Number).default(0.6),
|
||||
CALL_ANALYSIS_TIMEOUT_MS: z.string().transform(Number).default(200),
|
||||
});
|
||||
|
||||
export const spamShieldEnv = envSchema.parse({
|
||||
HIYA_API_KEY: process.env.HIYA_API_KEY,
|
||||
HIYA_API_URL: process.env.HIYA_API_URL,
|
||||
TRUECALLER_API_KEY: process.env.TRUECALLER_API_KEY,
|
||||
BERT_MODEL_PATH: process.env.BERT_MODEL_PATH,
|
||||
SPAM_THRESHOLD_AUTO_BLOCK: process.env.SPAM_THRESHOLD_AUTO_BLOCK,
|
||||
SPAM_THRESHOLD_FLAG: process.env.SPAM_THRESHOLD_FLAG,
|
||||
CALL_ANALYSIS_TIMEOUT_MS: process.env.CALL_ANALYSIS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Spam detection layers
|
||||
export enum SpamLayer {
|
||||
NUMBER_REPUTATION = 'number_reputation',
|
||||
CONTENT_CLASSIFICATION = 'content_classification',
|
||||
BEHAVIORAL_ANALYSIS = 'behavioral_analysis',
|
||||
COMMUNITY_INTELLIGENCE = 'community_intelligence',
|
||||
}
|
||||
|
||||
// Spam decision types
|
||||
export enum SpamDecision {
|
||||
ALLOW = 'allow',
|
||||
FLAG = 'flag',
|
||||
BLOCK = 'block',
|
||||
CHALLENGE = 'challenge',
|
||||
}
|
||||
|
||||
// Confidence levels
|
||||
export enum ConfidenceLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
VERY_HIGH = 'very_high',
|
||||
}
|
||||
|
||||
// Feature flags for spam detection
|
||||
export const spamFeatureFlags = {
|
||||
enableNumberReputation: true,
|
||||
enableContentClassification: true,
|
||||
enableBehavioralAnalysis: true,
|
||||
enableCommunityIntelligence: true,
|
||||
enableRealTimeBlocking: true,
|
||||
};
|
||||
|
||||
// Rate limits for spam analysis
|
||||
export const spamRateLimits = {
|
||||
basic: {
|
||||
analysesPerMinute: 10,
|
||||
analysesPerDay: 100,
|
||||
},
|
||||
plus: {
|
||||
analysesPerMinute: 50,
|
||||
analysesPerDay: 1000,
|
||||
},
|
||||
premium: {
|
||||
analysesPerMinute: 200,
|
||||
analysesPerDay: 10000,
|
||||
},
|
||||
};
|
||||
307
apps/api/src/services/spamshield/spamshield.service.ts
Normal file
307
apps/api/src/services/spamshield/spamshield.service.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { prisma, SpamRule, SpamFeedback, User } from '@shieldsai/shared-db';
|
||||
import { spamShieldEnv, SpamDecision, ConfidenceLevel } from './spamshield.config';
|
||||
|
||||
// Number reputation service (Hiya API integration)
|
||||
export class NumberReputationService {
|
||||
/**
|
||||
* Check number reputation using Hiya API
|
||||
*/
|
||||
async checkReputation(phoneNumber: string): Promise<{
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
spamType?: string;
|
||||
reportCount: number;
|
||||
}> {
|
||||
try {
|
||||
// TODO: Integrate with Hiya API
|
||||
// const response = await fetch(`${spamShieldEnv.HIYA_API_URL}/lookup`, {
|
||||
// headers: { 'X-API-Key': spamShieldEnv.HIYA_API_KEY },
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify({ phone: phoneNumber }),
|
||||
// });
|
||||
|
||||
// Simulated response for now
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0.1,
|
||||
spamType: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking number reputation:', error);
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0.0,
|
||||
reportCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check number against multiple reputation sources
|
||||
*/
|
||||
async checkMultiSource(phoneNumber: string): Promise<{
|
||||
hiya: { isSpam: boolean; confidence: number };
|
||||
truecaller: { isSpam: boolean; confidence: number } | null;
|
||||
combinedScore: number;
|
||||
}> {
|
||||
const hiyaResult = await this.checkReputation(phoneNumber);
|
||||
|
||||
let truecallerResult: { isSpam: boolean; confidence: number } | null = null;
|
||||
if (spamShieldEnv.TRUECALLER_API_KEY) {
|
||||
// TODO: Integrate Truecaller
|
||||
truecallerResult = {
|
||||
isSpam: false,
|
||||
confidence: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
// Weighted average: Hiya 70%, Truecaller 30%
|
||||
const combinedScore = hiyaResult.confidence * 0.7 +
|
||||
(truecallerResult?.confidence ?? 0) * 0.3;
|
||||
|
||||
return {
|
||||
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
|
||||
truecaller: truecallerResult,
|
||||
combinedScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// SMS content classifier (BERT-based)
|
||||
export class SMSClassifierService {
|
||||
private model: any = null; // BERT model placeholder
|
||||
|
||||
/**
|
||||
* Initialize the BERT model
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// TODO: Load BERT model from path
|
||||
// this.model = await loadBERTModel(spamShieldEnv.BERT_MODEL_PATH);
|
||||
console.log('SMS classifier initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify SMS text as spam or ham
|
||||
*/
|
||||
async classify(smsText: string): Promise<{
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
spamFeatures: string[];
|
||||
}> {
|
||||
if (!this.model) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Extract features
|
||||
const features = this.extractFeatures(smsText);
|
||||
|
||||
// TODO: Run through BERT model
|
||||
// const prediction = await this.model.predict(smsText);
|
||||
|
||||
// Simulated prediction
|
||||
const confidence = this.calculateConfidence(features);
|
||||
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
confidence,
|
||||
spamFeatures: features,
|
||||
};
|
||||
}
|
||||
|
||||
private extractFeatures(text: string): string[] {
|
||||
const features: string[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// URL presence
|
||||
if (/(http|www)\./i.test(text)) {
|
||||
features.push('url_present');
|
||||
}
|
||||
|
||||
// Emoji density
|
||||
const emojiCount = (text.match(/[\p{Emoji}]/gu) || []).length;
|
||||
if (emojiCount / text.length > 0.1) {
|
||||
features.push('high_emoji_density');
|
||||
}
|
||||
|
||||
// Urgency keywords
|
||||
const urgencyWords = ['now', 'urgent', 'limited', 'act fast', 'today'];
|
||||
if (urgencyWords.some(word => lowerText.includes(word))) {
|
||||
features.push('urgency_keyword');
|
||||
}
|
||||
|
||||
// Excessive capitalization
|
||||
if (/[A-Z]{3,}/.test(text)) {
|
||||
features.push('excessive_caps');
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
private calculateConfidence(features: string[]): number {
|
||||
const baseConfidence = 0.5;
|
||||
const featureWeights: Record<string, number> = {
|
||||
url_present: 0.1,
|
||||
high_emoji_density: 0.15,
|
||||
urgency_keyword: 0.2,
|
||||
excessive_caps: 0.15,
|
||||
};
|
||||
|
||||
return Math.min(1.0, baseConfidence +
|
||||
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Call analysis service
|
||||
export class CallAnalysisService {
|
||||
/**
|
||||
* Analyze incoming call for spam indicators
|
||||
*/
|
||||
async analyzeCall(callData: {
|
||||
phoneNumber: string;
|
||||
duration?: number;
|
||||
callTime: Date;
|
||||
isVoip?: boolean;
|
||||
}): Promise<{
|
||||
decision: SpamDecision;
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}> {
|
||||
const reasons: string[] = [];
|
||||
let spamScore = 0.0;
|
||||
|
||||
// Number reputation check
|
||||
const reputationService = new NumberReputationService();
|
||||
const reputation = await reputationService.checkMultiSource(callData.phoneNumber);
|
||||
|
||||
if (reputation.combinedScore > 0.7) {
|
||||
spamScore += reputation.combinedScore * 0.4;
|
||||
reasons.push('high_spam_reputation');
|
||||
}
|
||||
|
||||
// Behavioral analysis
|
||||
if (callData.duration && callData.duration < 10) {
|
||||
spamScore += 0.2;
|
||||
reasons.push('short_duration');
|
||||
}
|
||||
|
||||
if (callData.isVoip) {
|
||||
spamScore += 0.15;
|
||||
reasons.push('voip_number');
|
||||
}
|
||||
|
||||
// Time-of-day anomaly (simplified)
|
||||
const hour = callData.callTime.getHours();
|
||||
if (hour < 6 || hour > 22) {
|
||||
spamScore += 0.1;
|
||||
reasons.push('unusual_hours');
|
||||
}
|
||||
|
||||
// Determine decision
|
||||
let decision: SpamDecision;
|
||||
if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK) {
|
||||
decision = SpamDecision.BLOCK;
|
||||
} else if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_FLAG) {
|
||||
decision = SpamDecision.FLAG;
|
||||
} else {
|
||||
decision = SpamDecision.ALLOW;
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
confidence: spamScore,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// User feedback service
|
||||
export class SpamFeedbackService {
|
||||
/**
|
||||
* Record user feedback on spam detection
|
||||
*/
|
||||
async recordFeedback(
|
||||
userId: string,
|
||||
phoneNumber: string,
|
||||
isSpam: boolean,
|
||||
confidence?: number,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<SpamFeedback> {
|
||||
const phoneNumberHash = this.hashPhoneNumber(phoneNumber);
|
||||
|
||||
const feedback = await prisma.spamFeedback.create({
|
||||
data: {
|
||||
userId,
|
||||
phoneNumber,
|
||||
phoneNumberHash,
|
||||
isSpam,
|
||||
confidence,
|
||||
feedbackType: 'user_confirmation',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spam history for a user
|
||||
*/
|
||||
async getSpamHistory(
|
||||
userId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
isSpam?: boolean;
|
||||
startDate?: Date;
|
||||
}
|
||||
): Promise<SpamFeedback[]> {
|
||||
return prisma.spamFeedback.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.isSpam !== undefined && { isSpam: options.isSpam }),
|
||||
...(options?.startDate && { createdAt: { gte: options.startDate } }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit ?? 100,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a user
|
||||
*/
|
||||
async getStatistics(userId: string): Promise<{
|
||||
totalAnalyses: number;
|
||||
spamCount: number;
|
||||
hamCount: number;
|
||||
spamPercentage: number;
|
||||
}> {
|
||||
const [total, spam] = await Promise.all([
|
||||
prisma.spamFeedback.count({ where: { userId } }),
|
||||
prisma.spamFeedback.count({ where: { userId, isSpam: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalAnalyses: total,
|
||||
spamCount: spam,
|
||||
hamCount: total - spam,
|
||||
spamPercentage: total > 0 ? (spam / total) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
private hashPhoneNumber(phoneNumber: string): string {
|
||||
// Simple hash for demonstration
|
||||
let hash = 0;
|
||||
for (let i = 0; i < phoneNumber.length; i++) {
|
||||
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return `hash_${Math.abs(hash)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export instances
|
||||
export const numberReputationService = new NumberReputationService();
|
||||
export const smsClassifierService = new SMSClassifierService();
|
||||
export const callAnalysisService = new CallAnalysisService();
|
||||
export const spamFeedbackService = new SpamFeedbackService();
|
||||
26
apps/api/src/services/voiceprint/index.ts
Normal file
26
apps/api/src/services/voiceprint/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Config
|
||||
export {
|
||||
voicePrintEnv,
|
||||
VoicePrintSource,
|
||||
AnalysisJobStatus,
|
||||
DetectionType,
|
||||
ConfidenceLevel,
|
||||
audioPreprocessingConfig,
|
||||
voicePrintFeatureFlags,
|
||||
voicePrintRateLimits,
|
||||
} from './voiceprint.config';
|
||||
|
||||
// Services
|
||||
export {
|
||||
AudioPreprocessor,
|
||||
VoiceEnrollmentService,
|
||||
AnalysisService,
|
||||
BatchAnalysisService,
|
||||
EmbeddingService,
|
||||
FAISSIndex,
|
||||
audioPreprocessor,
|
||||
voiceEnrollmentService,
|
||||
analysisService,
|
||||
batchAnalysisService,
|
||||
embeddingService,
|
||||
} from './voiceprint.service';
|
||||
101
apps/api/src/services/voiceprint/voiceprint.config.ts
Normal file
101
apps/api/src/services/voiceprint/voiceprint.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables for VoicePrint
|
||||
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),
|
||||
});
|
||||
|
||||
export const voicePrintEnv = envSchema.parse({
|
||||
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,
|
||||
AUDIO_STORAGE_BUCKET: process.env.AUDIO_STORAGE_BUCKET,
|
||||
AUDIO_STORAGE_ENDPOINT: process.env.AUDIO_STORAGE_ENDPOINT,
|
||||
SYNTHETIC_THRESHOLD: process.env.SYNTHETIC_THRESHOLD,
|
||||
ENROLLMENT_MIN_DURATION_SEC: process.env.ENROLLMENT_MIN_DURATION_SEC,
|
||||
ENROLLMENT_MAX_DURATION_SEC: process.env.ENROLLMENT_MAX_DURATION_SEC,
|
||||
EMBEDDING_DIMENSIONS: process.env.EMBEDDING_DIMENSIONS,
|
||||
BATCH_MAX_FILES: process.env.BATCH_MAX_FILES,
|
||||
ANALYSIS_TIMEOUT_MS: process.env.ANALYSIS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Audio source types
|
||||
export enum VoicePrintSource {
|
||||
UPLOAD = 'upload',
|
||||
S3 = 's3',
|
||||
URL = 'url',
|
||||
REALTIME = 'realtime',
|
||||
}
|
||||
|
||||
// Analysis job status
|
||||
export enum AnalysisJobStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
// Detection result types
|
||||
export enum DetectionType {
|
||||
SYNTHETIC_VOICE = 'synthetic_voice',
|
||||
VOICE_CLONE = 'voice_clone',
|
||||
DEEPFAKE = 'deepfake',
|
||||
NATURAL = 'natural',
|
||||
}
|
||||
|
||||
// Confidence levels
|
||||
export enum ConfidenceLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
VERY_HIGH = 'very_high',
|
||||
}
|
||||
|
||||
// Audio preprocessing configuration
|
||||
export const audioPreprocessingConfig = {
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
bitDepth: 16,
|
||||
vadThreshold: 0.5,
|
||||
noiseReduction: true,
|
||||
maxSilenceDurationMs: 500,
|
||||
};
|
||||
|
||||
// Feature flags
|
||||
export const voicePrintFeatureFlags = {
|
||||
enableMLService: false,
|
||||
enableFAISSIndex: true,
|
||||
enableBatchAnalysis: true,
|
||||
enableRealtimeAnalysis: false,
|
||||
enableMockModel: true,
|
||||
};
|
||||
|
||||
// Rate limits for voice analysis
|
||||
export const voicePrintRateLimits = {
|
||||
basic: {
|
||||
analysesPerMinute: 5,
|
||||
enrollmentsPerDay: 10,
|
||||
maxAudioFileSizeMB: 50,
|
||||
},
|
||||
plus: {
|
||||
analysesPerMinute: 30,
|
||||
enrollmentsPerDay: 50,
|
||||
maxAudioFileSizeMB: 200,
|
||||
},
|
||||
premium: {
|
||||
analysesPerMinute: 100,
|
||||
enrollmentsPerDay: 500,
|
||||
maxAudioFileSizeMB: 500,
|
||||
},
|
||||
};
|
||||
592
apps/api/src/services/voiceprint/voiceprint.service.ts
Normal file
592
apps/api/src/services/voiceprint/voiceprint.service.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import { prisma, VoiceEnrollment, VoiceAnalysis } from '@shieldsai/shared-db';
|
||||
import {
|
||||
voicePrintEnv,
|
||||
AnalysisJobStatus,
|
||||
DetectionType,
|
||||
ConfidenceLevel,
|
||||
audioPreprocessingConfig,
|
||||
} from './voiceprint.config';
|
||||
|
||||
// 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<Buffer> {
|
||||
// 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<VoiceEnrollment> {
|
||||
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<VoiceEnrollment[]> {
|
||||
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<VoiceEnrollment | null> {
|
||||
return prisma.voiceEnrollment.findFirst({
|
||||
where: {
|
||||
id: enrollmentId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove (deactivate) an enrollment.
|
||||
*/
|
||||
async removeEnrollment(
|
||||
enrollmentId: string,
|
||||
userId: string
|
||||
): Promise<VoiceEnrollment> {
|
||||
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<Array<{ enrollment: VoiceEnrollment; similarity: number }>> {
|
||||
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 {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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<VoiceAnalysis> {
|
||||
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<VoiceAnalysis | null> {
|
||||
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<VoiceAnalysis[]> {
|
||||
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 {
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 analysisService = new AnalysisService();
|
||||
const results: VoiceAnalysis[] = [];
|
||||
let synthetic = 0;
|
||||
let natural = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const result = await analysisService.analyze(userId, file.buffer, {
|
||||
enrollmentId: options?.enrollmentId,
|
||||
audioUrl: file.audioUrl,
|
||||
});
|
||||
results.push(result);
|
||||
if (result.isSynthetic) {
|
||||
synthetic++;
|
||||
} else {
|
||||
natural++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Batch analysis failed for ${file.name}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const jobId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
jobId,
|
||||
results,
|
||||
summary: {
|
||||
total: files.length,
|
||||
synthetic,
|
||||
natural,
|
||||
failed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Embedding service — ECAPA-TDNN inference wrapper
|
||||
export class EmbeddingService {
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the ECAPA-TDNN model.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// TODO: Connect to Python ML service for real inference
|
||||
// const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/initialize`, {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify({ modelPath: voicePrintEnv.ECAPA_TDNN_MODEL_PATH }),
|
||||
// });
|
||||
|
||||
this.initialized = true;
|
||||
console.log('Embedding service initialized (mock model)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract voice embedding from audio.
|
||||
*/
|
||||
async extract(audioBuffer: Buffer): Promise<number[]> {
|
||||
await this.initialize();
|
||||
|
||||
// TODO: Call Python ML service
|
||||
// const response = await fetch(`${voicePrintEnv.ML_SERVICE_URL}/embed`, {
|
||||
// method: 'POST',
|
||||
// body: audioBuffer,
|
||||
// });
|
||||
// const data = await response.json();
|
||||
// return data.embedding;
|
||||
|
||||
// Mock: generate deterministic embedding based on buffer content
|
||||
const dims = voicePrintEnv.EMBEDDING_DIMENSIONS;
|
||||
const embedding: number[] = new Array(dims);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < Math.min(audioBuffer.length, 256); i++) {
|
||||
hash = ((hash << 5) - hash) + audioBuffer[i];
|
||||
hash |= 0;
|
||||
}
|
||||
for (let i = 0; i < dims; i++) {
|
||||
hash = ((hash << 5) - hash) + i;
|
||||
hash |= 0;
|
||||
embedding[i] = (Math.abs(hash) % 1000) / 1000.0;
|
||||
}
|
||||
|
||||
// L2 normalize
|
||||
const norm = Math.sqrt(embedding.reduce((s, v) => s + v * v, 0));
|
||||
return embedding.map((v) => v / norm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full analysis: embedding + synthetic detection.
|
||||
*/
|
||||
async analyze(audioBuffer: Buffer): Promise<{
|
||||
confidence: number;
|
||||
detectionType: DetectionType;
|
||||
features: Record<string, number>;
|
||||
embedding: number[];
|
||||
}> {
|
||||
const embedding = await this.extract(audioBuffer);
|
||||
|
||||
// TODO: Run synthetic voice detection model
|
||||
// For MVP, use heuristic based on embedding statistics
|
||||
const confidence = this.estimateSyntheticConfidence(audioBuffer, embedding);
|
||||
const detectionType =
|
||||
confidence >= voicePrintEnv.SYNTHETIC_THRESHOLD
|
||||
? DetectionType.SYNTHETIC_VOICE
|
||||
: DetectionType.NATURAL;
|
||||
|
||||
const features = this.extractAnalysisFeatures(audioBuffer, embedding);
|
||||
|
||||
return {
|
||||
confidence,
|
||||
detectionType,
|
||||
features,
|
||||
embedding,
|
||||
};
|
||||
}
|
||||
|
||||
private estimateSyntheticConfidence(
|
||||
buffer: Buffer,
|
||||
embedding: number[]
|
||||
): number {
|
||||
// Heuristic features for synthetic detection
|
||||
const meanAmplitude =
|
||||
buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
||||
const embeddingStdDev =
|
||||
Math.sqrt(
|
||||
embedding.reduce((s, v) => s + (v - embedding.reduce((a, b) => a + b) / embedding.length) ** 2, 0) /
|
||||
embedding.length
|
||||
) || 0;
|
||||
|
||||
// Combine features into confidence score
|
||||
const amplitudeScore = Math.abs(meanAmplitude - 0.5) * 2;
|
||||
const embeddingScore = 1.0 - Math.min(1.0, embeddingStdDev * 2);
|
||||
|
||||
return Math.min(
|
||||
1.0,
|
||||
amplitudeScore * 0.3 + embeddingScore * 0.4 + Math.random() * 0.3
|
||||
);
|
||||
}
|
||||
|
||||
private extractAnalysisFeatures(
|
||||
buffer: Buffer,
|
||||
embedding: number[]
|
||||
): Record<string, number> {
|
||||
const meanAmplitude =
|
||||
buffer.reduce((s, v) => s + v, 0) / buffer.length / 255;
|
||||
const zeroCrossings = buffer.reduce((count, v, i, arr) => {
|
||||
return i > 0 && ((v - 128) * (arr[i - 1] - 128) < 0) ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
mean_amplitude: meanAmplitude,
|
||||
zero_crossing_rate: zeroCrossings / buffer.length,
|
||||
embedding_energy: embedding.reduce((s, v) => s + v * v, 0),
|
||||
embedding_entropy: this.calculateEntropy(embedding),
|
||||
};
|
||||
}
|
||||
|
||||
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<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// TODO: Load FAISS index from disk
|
||||
// const faiss = require('faiss-node');
|
||||
// this.index = faiss.readIndex(this.indexPath);
|
||||
|
||||
this.initialized = true;
|
||||
console.log(`FAISS index initialized at ${this.indexPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an enrollment embedding to the index.
|
||||
*/
|
||||
async add(enrollmentId: string, embedding: number[]): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
// TODO: Add to FAISS index
|
||||
// this.index.add([embedding]);
|
||||
// Store mapping: enrollmentId -> index position
|
||||
console.log(`Added enrollment ${enrollmentId} to FAISS index`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an enrollment from the index.
|
||||
*/
|
||||
async remove(enrollmentId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
// TODO: Remove from FAISS index
|
||||
console.log(`Removed enrollment ${enrollmentId} from FAISS index`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for similar voice embeddings.
|
||||
*/
|
||||
async search(
|
||||
embedding: number[],
|
||||
topK: number = 5
|
||||
): Promise<Array<{ id: string; similarity: number }>> {
|
||||
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<void> {
|
||||
await this.initialize();
|
||||
// TODO: Write FAISS index to disk
|
||||
console.log(`FAISS index saved to ${this.indexPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instances
|
||||
export const audioPreprocessor = new AudioPreprocessor();
|
||||
export const voiceEnrollmentService = new VoiceEnrollmentService();
|
||||
export const analysisService = new AnalysisService();
|
||||
export const batchAnalysisService = new BatchAnalysisService();
|
||||
export const embeddingService = new EmbeddingService();
|
||||
22
apps/mobile/package.json
Normal file
22
apps/mobile/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14",
|
||||
"@shieldsai/shared-auth": "*",
|
||||
"@shieldsai/shared-ui": "*",
|
||||
"@shieldsai/shared-utils": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"@types/node": "^25.6.0"
|
||||
}
|
||||
}
|
||||
24
apps/web/package.json
Normal file
24
apps/web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14",
|
||||
"@shieldsai/shared-auth": "*",
|
||||
"@shieldsai/shared-ui": "*",
|
||||
"@shieldsai/shared-utils": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-solid": "^2.8.2",
|
||||
"@types/node": "^25.6.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user