for first push
This commit is contained in:
183
services/voiceprint/src/analysis/AnalysisService.ts
Normal file
183
services/voiceprint/src/analysis/AnalysisService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { AudioPreprocessor, AudioFeatures } from "../preprocessor/AudioPreprocessor";
|
||||
import { EmbeddingService, EmbeddingOutput } from "../embedding/EmbeddingService";
|
||||
import { VoiceEnrollmentService } from "../enrollment/VoiceEnrollmentService";
|
||||
import {
|
||||
AnalyzeAudioInput,
|
||||
AnalysisJobStatus,
|
||||
AnalysisType,
|
||||
DetectionVerdict,
|
||||
AnalysisResultOutput,
|
||||
} from "@shieldai/types";
|
||||
|
||||
export class AnalysisService {
|
||||
private preprocessor: AudioPreprocessor;
|
||||
private embeddingService: EmbeddingService;
|
||||
private enrollmentService: VoiceEnrollmentService;
|
||||
private readonly syntheticThreshold = 0.7;
|
||||
private readonly uncertainThreshold = 0.4;
|
||||
|
||||
constructor() {
|
||||
this.preprocessor = new AudioPreprocessor();
|
||||
this.embeddingService = new EmbeddingService();
|
||||
this.enrollmentService = new VoiceEnrollmentService();
|
||||
}
|
||||
|
||||
async analyze(input: AnalyzeAudioInput, userId: string): Promise<AnalysisResultOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const job = await prisma.analysisJob.create({
|
||||
data: {
|
||||
userId,
|
||||
analysisType: input.analysisType || AnalysisType.SYNTHETIC_DETECTION,
|
||||
audioFilePath: `voiceprint/${userId}/${Date.now()}.wav`,
|
||||
status: AnalysisJobStatus.RUNNING,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const preprocessed = await this.preprocessor.preprocess(input.audioBuffer, input.sampleRate);
|
||||
const features = await this.preprocessor.extractFeatures(preprocessed.audio);
|
||||
const embedding = await this.embeddingService.extract(preprocessed.audio);
|
||||
|
||||
const syntheticScore = await this.classifySynthetic(features, embedding);
|
||||
const verdict = this.determineVerdict(syntheticScore);
|
||||
const confidence = this.computeConfidence(syntheticScore, verdict);
|
||||
|
||||
let matchedEnrollmentId: string | undefined;
|
||||
let matchedSimilarity: number | undefined;
|
||||
|
||||
if (input.analysisType === AnalysisType.VOICE_MATCH) {
|
||||
const match = await this.enrollmentService.matchVoice(input.audioBuffer, userId);
|
||||
if (match) {
|
||||
matchedEnrollmentId = match.enrollmentId;
|
||||
matchedSimilarity = match.similarity;
|
||||
}
|
||||
}
|
||||
|
||||
const processingTimeMs = Date.now() - startTime;
|
||||
|
||||
const result = await prisma.analysisResult.create({
|
||||
data: {
|
||||
analysisJobId: job.id,
|
||||
syntheticScore,
|
||||
verdict,
|
||||
confidence,
|
||||
processingTimeMs,
|
||||
matchedEnrollmentId,
|
||||
matchedSimilarity,
|
||||
modelVersion: this.embeddingService.getModelVersion(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.analysisJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: AnalysisJobStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
syntheticScore: result.syntheticScore,
|
||||
verdict: result.verdict,
|
||||
confidence: result.confidence,
|
||||
matchedEnrollmentId: result.matchedEnrollmentId || undefined,
|
||||
matchedSimilarity: result.matchedSimilarity || undefined,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
modelVersion: result.modelVersion || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Analysis failed";
|
||||
await prisma.analysisJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: AnalysisJobStatus.FAILED,
|
||||
errorMessage: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getResult(jobId: string): Promise<AnalysisResultOutput | null> {
|
||||
const job = await prisma.analysisJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { result: true },
|
||||
});
|
||||
|
||||
if (!job || !job.result) return null;
|
||||
|
||||
const r = job.result;
|
||||
return {
|
||||
jobId,
|
||||
syntheticScore: r.syntheticScore,
|
||||
verdict: r.verdict,
|
||||
confidence: r.confidence,
|
||||
matchedEnrollmentId: r.matchedEnrollmentId || undefined,
|
||||
matchedSimilarity: r.matchedSimilarity || undefined,
|
||||
processingTimeMs: r.processingTimeMs,
|
||||
modelVersion: r.modelVersion || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserResults(userId: string, limit: number = 20): Promise<AnalysisResultOutput[]> {
|
||||
const jobs = await prisma.analysisJob.findMany({
|
||||
where: { userId, status: AnalysisJobStatus.COMPLETED },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: { result: true },
|
||||
});
|
||||
|
||||
return jobs
|
||||
.filter((j) => j.result)
|
||||
.map((j) => {
|
||||
const r = j.result!;
|
||||
return {
|
||||
jobId: j.id,
|
||||
syntheticScore: r.syntheticScore,
|
||||
verdict: r.verdict,
|
||||
confidence: r.confidence,
|
||||
matchedEnrollmentId: r.matchedEnrollmentId || undefined,
|
||||
matchedSimilarity: r.matchedSimilarity || undefined,
|
||||
processingTimeMs: r.processingTimeMs,
|
||||
modelVersion: r.modelVersion || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async classifySynthetic(
|
||||
features: AudioFeatures,
|
||||
embedding: EmbeddingOutput
|
||||
): Promise<number> {
|
||||
const modelScore = await this.embeddingService.classify(embedding.vector);
|
||||
|
||||
const zcrAnomaly = Math.abs(features.zeroCrossingRate - 0.05) / 0.05;
|
||||
const spectralAnomaly = Math.abs(features.spectralCentroid - 500) / 500;
|
||||
|
||||
const artifactScore = Math.min(1, (zcrAnomaly + spectralAnomaly) / 4);
|
||||
|
||||
return 0.7 * modelScore + 0.3 * artifactScore;
|
||||
}
|
||||
|
||||
private determineVerdict(score: number): DetectionVerdict {
|
||||
if (score >= this.syntheticThreshold) return DetectionVerdict.SYNTHETIC;
|
||||
if (score <= this.uncertainThreshold) return DetectionVerdict.NATURAL;
|
||||
return DetectionVerdict.UNCERTAIN;
|
||||
}
|
||||
|
||||
private computeConfidence(score: number, verdict: DetectionVerdict): number {
|
||||
if (verdict === DetectionVerdict.SYNTHETIC) {
|
||||
return Math.min(1, (score - this.syntheticThreshold) / (1 - this.syntheticThreshold));
|
||||
}
|
||||
if (verdict === DetectionVerdict.NATURAL) {
|
||||
return Math.min(1, (this.uncertainThreshold - score) / this.uncertainThreshold);
|
||||
}
|
||||
return 1 - Math.min(
|
||||
Math.abs(score - this.uncertainThreshold) / (this.syntheticThreshold - this.uncertainThreshold),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
125
services/voiceprint/src/analysis/BatchAnalysisService.ts
Normal file
125
services/voiceprint/src/analysis/BatchAnalysisService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { AnalysisService } from "./AnalysisService";
|
||||
import {
|
||||
BatchAnalyzeInput,
|
||||
AnalysisJobStatus,
|
||||
AnalysisType,
|
||||
AnalysisResultOutput,
|
||||
} from "@shieldai/types";
|
||||
|
||||
export class BatchAnalysisService {
|
||||
private analysisService: AnalysisService;
|
||||
|
||||
constructor() {
|
||||
this.analysisService = new AnalysisService();
|
||||
}
|
||||
|
||||
async analyzeBatch(
|
||||
input: BatchAnalyzeInput,
|
||||
userId: string
|
||||
): Promise<BatchResult> {
|
||||
const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
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 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(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batchId,
|
||||
jobId: batchJob.id,
|
||||
totalFiles: input.audioBuffers.length,
|
||||
successfulResults: results.length,
|
||||
failedCount: errors.length,
|
||||
results,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async getBatchResult(batchJobId: string): Promise<BatchResult | null> {
|
||||
const job = await prisma.analysisJob.findUnique({
|
||||
where: { id: batchJobId },
|
||||
include: { result: true },
|
||||
});
|
||||
|
||||
if (!job) return null;
|
||||
|
||||
const childJobs = await prisma.analysisJob.findMany({
|
||||
where: {
|
||||
userId: job.userId,
|
||||
createdAt: { gte: job.createdAt, lt: new Date(job.createdAt.getTime() + 60000) },
|
||||
id: { not: job.id },
|
||||
},
|
||||
include: { result: true },
|
||||
});
|
||||
|
||||
const results: AnalysisResultOutput[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const childJob of childJobs) {
|
||||
if (childJob.result) {
|
||||
const r = childJob.result;
|
||||
results.push({
|
||||
jobId: childJob.id,
|
||||
syntheticScore: r.syntheticScore,
|
||||
verdict: r.verdict,
|
||||
confidence: r.confidence,
|
||||
matchedEnrollmentId: r.matchedEnrollmentId || undefined,
|
||||
matchedSimilarity: r.matchedSimilarity || undefined,
|
||||
processingTimeMs: r.processingTimeMs,
|
||||
modelVersion: r.modelVersion || undefined,
|
||||
});
|
||||
} else if (childJob.errorMessage) {
|
||||
errors.push({ name: childJob.audioFilePath, error: childJob.errorMessage });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
batchId: job.audioFilePath.split("/").pop() || job.id,
|
||||
jobId: job.id,
|
||||
totalFiles: childJobs.length,
|
||||
successfulResults: results.length,
|
||||
failedCount: errors.length,
|
||||
results,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
batchId: string;
|
||||
jobId: string;
|
||||
totalFiles: number;
|
||||
successfulResults: number;
|
||||
failedCount: number;
|
||||
results: AnalysisResultOutput[];
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
}
|
||||
Reference in New Issue
Block a user