for first push
This commit is contained in:
19
services/voiceprint/package.json
Normal file
19
services/voiceprint/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@shieldai/voiceprint",
|
||||
"version": "0.1.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shieldai/db": "0.1.0",
|
||||
"@shieldai/types": "0.1.0",
|
||||
"node-cache": "^5.1.2"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
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 }>;
|
||||
}
|
||||
196
services/voiceprint/src/embedding/EmbeddingService.ts
Normal file
196
services/voiceprint/src/embedding/EmbeddingService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { spawn } from "child_process";
|
||||
|
||||
const EMBEDDING_DIM = 192;
|
||||
const MODEL_VERSION = "ecapa-tdnn-0.1.0-mock";
|
||||
|
||||
export class EmbeddingService {
|
||||
private mlServiceUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.mlServiceUrl = process.env.VOICEPRINT_ML_URL || "http://localhost:8001";
|
||||
}
|
||||
|
||||
async extract(audioBuffer: Buffer): Promise<EmbeddingOutput> {
|
||||
const mlAvailable = await this.checkMLService();
|
||||
|
||||
if (mlAvailable) {
|
||||
return this.extractViaML(audioBuffer);
|
||||
}
|
||||
|
||||
return this.extractMock(audioBuffer);
|
||||
}
|
||||
|
||||
async classify(embedding: number[]): Promise<number> {
|
||||
const mlAvailable = await this.checkMLService();
|
||||
|
||||
if (mlAvailable) {
|
||||
return this.classifyViaML(embedding);
|
||||
}
|
||||
|
||||
return this.classifyMock(embedding);
|
||||
}
|
||||
|
||||
getModelVersion(): string {
|
||||
return MODEL_VERSION;
|
||||
}
|
||||
|
||||
private async extractViaML(audioBuffer: Buffer): Promise<EmbeddingOutput> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const jsonInput = audioBuffer.toString("base64");
|
||||
const proc = spawn("python3", [
|
||||
"-c",
|
||||
`
|
||||
import urllib.request, json, sys
|
||||
req = urllib.request.Request(
|
||||
"${this.mlServiceUrl}/embedding",
|
||||
data=json.dumps({"audio": "${jsonInput.substring(0, 5000)}"}).encode(),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read())
|
||||
sys.stdout.write(json.dumps({"ok": True, "vector": data.get("embedding", []), "dim": data.get("dimension", ${EMBEDDING_DIM})}))
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}))
|
||||
`,
|
||||
]);
|
||||
|
||||
let output = "";
|
||||
proc.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||
proc.on("close", (code) => {
|
||||
try {
|
||||
const result = JSON.parse(output);
|
||||
if (result.ok && result.vector.length === EMBEDDING_DIM) {
|
||||
resolve({ vector: result.vector, dimension: EMBEDDING_DIM });
|
||||
} else {
|
||||
resolve(this.generateMockFromBuffer(audioBuffer));
|
||||
}
|
||||
} catch {
|
||||
resolve(this.generateMockFromBuffer(audioBuffer));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async classifyViaML(embedding: number[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("python3", [
|
||||
"-c",
|
||||
`
|
||||
import urllib.request, json, sys
|
||||
req = urllib.request.Request(
|
||||
"${this.mlServiceUrl}/classify",
|
||||
data=json.dumps({"embedding": ${JSON.stringify(embedding)}}).encode(),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
sys.stdout.write(json.dumps({"score": data.get("synthetic_score", 0.5)}))
|
||||
except:
|
||||
sys.stdout.write(json.dumps({"score": 0.5}))
|
||||
`,
|
||||
]);
|
||||
|
||||
let output = "";
|
||||
proc.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||
proc.on("close", () => {
|
||||
try {
|
||||
const result = JSON.parse(output);
|
||||
resolve(result.score || 0.5);
|
||||
} catch {
|
||||
resolve(0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async extractMock(audioBuffer: Buffer): Promise<EmbeddingOutput> {
|
||||
return this.generateMockFromBuffer(audioBuffer);
|
||||
}
|
||||
|
||||
private async classifyMock(embedding: number[]): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
private generateMockFromBuffer(audioBuffer: Buffer): EmbeddingOutput {
|
||||
const seed = this.computeSeed(audioBuffer);
|
||||
const rng = this.createRNG(seed);
|
||||
const vector: number[] = [];
|
||||
|
||||
for (let i = 0; i < EMBEDDING_DIM; 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: 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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("python3", [
|
||||
"-c",
|
||||
`
|
||||
import urllib.request, sys
|
||||
try:
|
||||
urllib.request.urlopen("${this.mlServiceUrl}/health", timeout=2)
|
||||
sys.exit(0)
|
||||
except:
|
||||
sys.exit(1)
|
||||
`,
|
||||
]);
|
||||
proc.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return (seed >>> 0) / 0xffffffff;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface EmbeddingOutput {
|
||||
vector: number[];
|
||||
dimension: number;
|
||||
}
|
||||
151
services/voiceprint/src/enrollment/VoiceEnrollmentService.ts
Normal file
151
services/voiceprint/src/enrollment/VoiceEnrollmentService.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { EmbeddingService } from "../embedding/EmbeddingService";
|
||||
import { FAISSIndex } from "../indexer/FAISSIndex";
|
||||
import { AudioPreprocessor } from "../preprocessor/AudioPreprocessor";
|
||||
import { VoiceEnrollmentInput, VoiceEnrollmentOutput } from "@shieldai/types";
|
||||
|
||||
export class VoiceEnrollmentService {
|
||||
private embeddingService: EmbeddingService;
|
||||
private faissIndex: FAISSIndex;
|
||||
private preprocessor: AudioPreprocessor;
|
||||
|
||||
constructor() {
|
||||
this.embeddingService = new EmbeddingService();
|
||||
this.faissIndex = new FAISSIndex();
|
||||
this.preprocessor = new AudioPreprocessor();
|
||||
}
|
||||
|
||||
async enroll(input: VoiceEnrollmentInput, userId: string): Promise<VoiceEnrollmentOutput> {
|
||||
const preprocessed = await this.preprocessor.preprocess(input.audioBuffer, input.sampleRate);
|
||||
|
||||
const embedding = await this.embeddingService.extract(preprocessed.audio);
|
||||
|
||||
const enrollment = await prisma.voiceEnrollment.create({
|
||||
data: {
|
||||
userId,
|
||||
label: input.label,
|
||||
embeddingVector: embedding.vector,
|
||||
embeddingDim: embedding.dimension,
|
||||
sampleRate: preprocessed.sampleRate,
|
||||
durationSec: preprocessed.durationSec,
|
||||
},
|
||||
});
|
||||
|
||||
await this.faissIndex.add(enrollment.id, embedding.vector);
|
||||
|
||||
return {
|
||||
id: enrollment.id,
|
||||
label: enrollment.label,
|
||||
embeddingDim: enrollment.embeddingDim,
|
||||
sampleRate: enrollment.sampleRate,
|
||||
durationSec: enrollment.durationSec,
|
||||
createdAt: enrollment.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listEnrollments(userId: string): Promise<VoiceEnrollmentOutput[]> {
|
||||
const enrollments = await prisma.voiceEnrollment.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
embeddingDim: true,
|
||||
sampleRate: true,
|
||||
durationSec: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return enrollments.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
embeddingDim: e.embeddingDim,
|
||||
sampleRate: e.sampleRate,
|
||||
durationSec: e.durationSec,
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async removeEnrollment(userId: string, enrollmentId: string): Promise<boolean> {
|
||||
const enrollment = await prisma.voiceEnrollment.findFirst({
|
||||
where: { id: enrollmentId, userId },
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
throw new Error(`Enrollment ${enrollmentId} not found for user ${userId}`);
|
||||
}
|
||||
|
||||
await prisma.voiceEnrollment.delete({ where: { id: enrollmentId } });
|
||||
await this.faissIndex.remove(enrollmentId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getEnrollment(enrollmentId: string): Promise<VoiceEnrollmentOutput | null> {
|
||||
const enrollment = await prisma.voiceEnrollment.findUnique({
|
||||
where: { id: enrollmentId },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
embeddingDim: true,
|
||||
sampleRate: true,
|
||||
durationSec: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!enrollment) return null;
|
||||
|
||||
return {
|
||||
id: enrollment.id,
|
||||
label: enrollment.label,
|
||||
embeddingDim: enrollment.embeddingDim,
|
||||
sampleRate: enrollment.sampleRate,
|
||||
durationSec: enrollment.durationSec,
|
||||
createdAt: enrollment.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async matchVoice(
|
||||
audioBuffer: Buffer,
|
||||
userId: string,
|
||||
threshold: number = 0.75
|
||||
): Promise<MatchResult | null> {
|
||||
const preprocessed = await this.preprocessor.preprocess(audioBuffer);
|
||||
const embedding = await this.embeddingService.extract(preprocessed.audio);
|
||||
|
||||
const matches = await this.faissIndex.search(embedding.vector, 5);
|
||||
|
||||
const enrollmentIds = matches.map((m) => m.id);
|
||||
const enrollments = await prisma.voiceEnrollment.findMany({
|
||||
where: {
|
||||
id: { in: enrollmentIds },
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
let bestMatch: MatchResult | null = null;
|
||||
|
||||
for (const match of matches) {
|
||||
const enrollment = enrollments.find((e) => e.id === match.id);
|
||||
if (enrollment && match.similarity >= threshold) {
|
||||
if (!bestMatch || match.similarity > bestMatch.similarity) {
|
||||
bestMatch = {
|
||||
enrollmentId: enrollment.id,
|
||||
label: enrollment.label,
|
||||
similarity: match.similarity,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
enrollmentId: string;
|
||||
label: string;
|
||||
similarity: number;
|
||||
}
|
||||
6
services/voiceprint/src/index.ts
Normal file
6
services/voiceprint/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./preprocessor/AudioPreprocessor";
|
||||
export * from "./enrollment/VoiceEnrollmentService";
|
||||
export * from "./analysis/AnalysisService";
|
||||
export * from "./analysis/BatchAnalysisService";
|
||||
export * from "./embedding/EmbeddingService";
|
||||
export * from "./indexer/FAISSIndex";
|
||||
86
services/voiceprint/src/indexer/FAISSIndex.ts
Normal file
86
services/voiceprint/src/indexer/FAISSIndex.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export class FAISSIndex {
|
||||
private store: Map<string, number[]> = new Map();
|
||||
private readonly dimension = 192;
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async add(id: string, vector: number[]): Promise<void> {
|
||||
this.normalizeInPlace(vector);
|
||||
this.store.set(id, [...vector]);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
this.store.delete(id);
|
||||
}
|
||||
|
||||
async search(
|
||||
queryVector: number[],
|
||||
topK: number = 5
|
||||
): Promise<SearchResult[]> {
|
||||
const normalized = [...queryVector];
|
||||
this.normalizeInPlace(normalized);
|
||||
|
||||
const scores: Array<{ id: string; similarity: number }> = [];
|
||||
|
||||
for (const [id, vector] of this.store.entries()) {
|
||||
const similarity = this.cosineSimilarity(normalized, vector);
|
||||
scores.push({ id, similarity });
|
||||
}
|
||||
|
||||
scores.sort((a, b) => b.similarity - a.similarity);
|
||||
return scores.slice(0, topK).map((s, i) => ({ rank: i + 1, id: s.id, similarity: s.similarity }));
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
async loadFromDatabase(): Promise<void> {
|
||||
const prisma = (await import("@shieldai/db")).default;
|
||||
const enrollments = await prisma.voiceEnrollment.findMany({
|
||||
select: { id: true, embeddingVector: true },
|
||||
});
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
this.store.set(enrollment.id, enrollment.embeddingVector);
|
||||
}
|
||||
}
|
||||
|
||||
private cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denominator > 0 ? dotProduct / denominator : 0;
|
||||
}
|
||||
|
||||
private normalizeInPlace(vector: number[]): void {
|
||||
const norm = Math.sqrt(vector.reduce((s, v) => s + v * v, 0));
|
||||
if (norm > 0) {
|
||||
for (let i = 0; i < vector.length; i++) {
|
||||
vector[i] /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
rank: number;
|
||||
id: string;
|
||||
similarity: number;
|
||||
}
|
||||
327
services/voiceprint/src/preprocessor/AudioPreprocessor.ts
Normal file
327
services/voiceprint/src/preprocessor/AudioPreprocessor.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { spawn } from "child_process";
|
||||
import { randomBytes } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
export class AudioPreprocessor {
|
||||
private readonly targetSampleRate = 16000;
|
||||
private readonly channels = 1;
|
||||
|
||||
async preprocess(audioBuffer: Buffer, inputSampleRate?: number): Promise<PreprocessedAudio> {
|
||||
const tempInput = this.writeTempFile(audioBuffer, ".wav");
|
||||
const tempOutput = this.writeTempFile(Buffer.alloc(0), ".wav");
|
||||
|
||||
const outputSampleRate = inputSampleRate || this.detectSampleRate(audioBuffer);
|
||||
|
||||
try {
|
||||
await this.runPythonPreprocess(tempInput, tempOutput, outputSampleRate);
|
||||
|
||||
const processed = await this.readFile(tempOutput);
|
||||
return {
|
||||
audio: processed,
|
||||
sampleRate: this.targetSampleRate,
|
||||
channels: this.channels,
|
||||
durationSec: processed.length / (this.targetSampleRate * this.channels * 2),
|
||||
};
|
||||
} catch (err) {
|
||||
const fallback = await this.jsFallback(audioBuffer, outputSampleRate);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async preprocessBatch(
|
||||
inputs: Array<{ buffer: Buffer; sampleRate?: number }>
|
||||
): Promise<PreprocessedAudio[]> {
|
||||
const promises = inputs.map(async (input) => {
|
||||
return this.preprocess(input.buffer, input.sampleRate);
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
applyVAD(audioBuffer: Buffer, sampleRate: number): Buffer {
|
||||
const int16Array = this.bufferToInt16(audioBuffer);
|
||||
const silenceThreshold = 500;
|
||||
const windowSize = Math.floor(sampleRate * 0.02);
|
||||
|
||||
const segments: number[][] = [];
|
||||
let currentSegment: number[] = [];
|
||||
|
||||
for (let i = 0; i < int16Array.length; i += windowSize) {
|
||||
const window = int16Array.slice(i, i + windowSize);
|
||||
const rms = Math.sqrt(
|
||||
window.reduce((sum, val) => sum + val * val, 0) / window.length
|
||||
);
|
||||
|
||||
if (rms > silenceThreshold) {
|
||||
currentSegment.push(...window);
|
||||
} else if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
const merged = segments.flat();
|
||||
return this.int16ToBuffer(merged);
|
||||
}
|
||||
|
||||
normalizeAudio(audioBuffer: Buffer): Buffer {
|
||||
const int16Array = this.bufferToInt16(audioBuffer);
|
||||
const maxAmplitude = Math.max(...int16Array.map((v) => Math.abs(v)));
|
||||
const targetMax = 32767 * 0.95;
|
||||
|
||||
if (maxAmplitude === 0) return audioBuffer;
|
||||
|
||||
const scale = targetMax / maxAmplitude;
|
||||
const normalized = int16Array.map((v) => Math.round(v * scale));
|
||||
return this.int16ToBuffer(normalized);
|
||||
}
|
||||
|
||||
async extractFeatures(audioBuffer: Buffer): Promise<AudioFeatures> {
|
||||
const int16Array = this.bufferToInt16(audioBuffer);
|
||||
const sampleRate = this.targetSampleRate;
|
||||
const windowSize = Math.floor(sampleRate * 0.025);
|
||||
const hopLength = Math.floor(sampleRate * 0.01);
|
||||
|
||||
const mfccs: number[][] = [];
|
||||
const numCoeffs = 13;
|
||||
|
||||
for (let i = 0; i < int16Array.length - windowSize; i += hopLength) {
|
||||
const frame = int16Array.slice(i, i + windowSize);
|
||||
const coeffs = this.computeMFCC(frame, numCoeffs);
|
||||
mfccs.push(coeffs);
|
||||
}
|
||||
|
||||
const zeroCrossingRate = this.computeZCR(int16Array);
|
||||
const spectralCentroid = this.computeSpectralCentroid(int16Array);
|
||||
const spectralRollOff = this.computeSpectralRollOff(int16Array);
|
||||
|
||||
return {
|
||||
mfccs,
|
||||
zeroCrossingRate,
|
||||
spectralCentroid,
|
||||
spectralRollOff,
|
||||
durationSec: int16Array.length / sampleRate,
|
||||
};
|
||||
}
|
||||
|
||||
private async runPythonPreprocess(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
inputSampleRate: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mlServiceUrl = process.env.VOICEPRINT_ML_URL || "http://localhost:8001";
|
||||
const proc = spawn("python3", [
|
||||
"-c",
|
||||
`
|
||||
import urllib.request, json, sys
|
||||
req = urllib.request.Request(
|
||||
"${mlServiceUrl}/preprocess",
|
||||
data=json.dumps({"input_path": "${inputPath}", "output_path": "${outputPath}", "input_sr": ${inputSampleRate}}).encode(),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
sys.exit(0) if resp.status == 200 else sys.exit(1)
|
||||
except:
|
||||
sys.exit(1)
|
||||
`,
|
||||
]);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
code === 0 ? resolve() : reject(new Error(`Python preprocess exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async jsFallback(
|
||||
audioBuffer: Buffer,
|
||||
inputSampleRate: number
|
||||
): Promise<PreprocessedAudio> {
|
||||
let processed = audioBuffer;
|
||||
|
||||
if (inputSampleRate !== this.targetSampleRate) {
|
||||
processed = this.resample(audioBuffer, inputSampleRate, this.targetSampleRate);
|
||||
}
|
||||
|
||||
processed = this.applyVAD(processed, this.targetSampleRate);
|
||||
processed = this.normalizeAudio(processed);
|
||||
|
||||
return {
|
||||
audio: processed,
|
||||
sampleRate: this.targetSampleRate,
|
||||
channels: this.channels,
|
||||
durationSec: processed.length / (this.targetSampleRate * this.channels * 2),
|
||||
};
|
||||
}
|
||||
|
||||
private resample(buffer: Buffer, fromRate: number, toRate: number): Buffer {
|
||||
const int16 = this.bufferToInt16(buffer);
|
||||
const ratio = fromRate / toRate;
|
||||
const newLength = Math.round(int16.length / ratio);
|
||||
const resampled: number[] = [];
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = Math.floor(i * ratio);
|
||||
const nextIdx = Math.min(srcIdx + 1, int16.length - 1);
|
||||
const frac = (i * ratio) - srcIdx;
|
||||
resampled.push(Math.round(int16[srcIdx] * (1 - frac) + int16[nextIdx] * frac));
|
||||
}
|
||||
|
||||
return this.int16ToBuffer(resampled);
|
||||
}
|
||||
|
||||
private detectSampleRate(buffer: Buffer): number {
|
||||
if (buffer.length < 44) return 16000;
|
||||
|
||||
const fmtOffset = buffer.toString("ascii", 12, 16).trim() === "fmt " ? 16 : 40;
|
||||
if (fmtOffset + 4 <= buffer.length) {
|
||||
const sr = buffer.readUInt32LE(fmtOffset + 4);
|
||||
if (sr >= 8000 && sr <= 48000) return sr;
|
||||
}
|
||||
return 16000;
|
||||
}
|
||||
|
||||
private bufferToInt16(buffer: Buffer): number[] {
|
||||
const arr: number[] = new Array(buffer.length / 2);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] = buffer.readInt16LE(i * 2);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private int16ToBuffer(arr: number[]): Buffer {
|
||||
const buf = Buffer.alloc(arr.length * 2);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
buf.writeInt16LE(arr[i], i * 2);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private computeMFCC(frame: number[], numCoeffs: number): number[] {
|
||||
const n = frame.length;
|
||||
const fftSize = Math.pow(2, Math.ceil(Math.log2(n)) + 1);
|
||||
const spectrum: number[] = new Array(fftSize / 2 + 1);
|
||||
|
||||
for (let k = 0; k < spectrum.length; k++) {
|
||||
let real = 0, imag = 0;
|
||||
for (let n = 0; n < frame.length; n++) {
|
||||
const angle = (2 * Math.PI * k * n) / fftSize;
|
||||
real += frame[n] * Math.cos(angle);
|
||||
imag -= frame[n] * Math.sin(angle);
|
||||
}
|
||||
spectrum[k] = Math.sqrt(real * real + imag * imag);
|
||||
}
|
||||
|
||||
const numFilters = 20;
|
||||
const filterbank = this.createFilterbank(spectrum.length, numFilters);
|
||||
const logEnergies: number[] = [];
|
||||
|
||||
for (let m = 0; m < numFilters; m++) {
|
||||
let energy = 0;
|
||||
for (let k = 0; k < spectrum.length; k++) {
|
||||
energy += filterbank[m][k] * spectrum[k] * spectrum[k];
|
||||
}
|
||||
logEnergies.push(Math.log(Math.max(energy, 1e-10)));
|
||||
}
|
||||
|
||||
const mfccs: number[] = [];
|
||||
for (let d = 0; d < numCoeffs; d++) {
|
||||
let coeff = 0;
|
||||
for (let m = 0; m < numFilters; m++) {
|
||||
coeff += logEnergies[m] * Math.cos(((2 * m + 1) * (d + 1) * Math.PI) / (2 * numFilters));
|
||||
}
|
||||
mfccs.push(coeff);
|
||||
}
|
||||
|
||||
return mfccs;
|
||||
}
|
||||
|
||||
private createFilterbank(numBins: number, numFilters: number): number[][] {
|
||||
const filterbank: number[][] = Array.from({ length: numFilters }, () =>
|
||||
new Array(numBins).fill(0)
|
||||
);
|
||||
const low = Math.floor(20 * numBins / 8000);
|
||||
const high = Math.floor(5500 * numBins / 8000);
|
||||
const spacing = (high - low) / (numFilters + 1);
|
||||
|
||||
for (let m = 1; m <= numFilters; m++) {
|
||||
const center = Math.floor(low + m * spacing);
|
||||
const prev = Math.floor(low + (m - 1) * spacing);
|
||||
const next = Math.floor(low + (m + 1) * spacing);
|
||||
|
||||
for (let k = prev; k < center; k++) {
|
||||
if (k > 0) filterbank[m - 1][k] = (k - prev) / (center - prev);
|
||||
}
|
||||
for (let k = center; k < next; k++) {
|
||||
if (next - center > 0) filterbank[m - 1][k] = (next - k) / (next - center);
|
||||
}
|
||||
}
|
||||
|
||||
return filterbank;
|
||||
}
|
||||
|
||||
private computeZCR(int16: number[]): number {
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < int16.length; i++) {
|
||||
if ((int16[i] > 0 && int16[i - 1] <= 0) || (int16[i] <= 0 && int16[i - 1] > 0)) {
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
return crossings / int16.length;
|
||||
}
|
||||
|
||||
private computeSpectralCentroid(int16: number[]): number {
|
||||
const n = int16.length;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += i * int16[i] * int16[i];
|
||||
den += int16[i] * int16[i];
|
||||
}
|
||||
return den > 0 ? num / den : 0;
|
||||
}
|
||||
|
||||
private computeSpectralRollOff(int16: number[]): number {
|
||||
const n = int16.length;
|
||||
let totalEnergy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
totalEnergy += int16[i] * int16[i];
|
||||
}
|
||||
const threshold = totalEnergy * 0.85;
|
||||
let cumulative = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
cumulative += int16[i] * int16[i];
|
||||
if (cumulative >= threshold) return i;
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
private writeTempFile(content: Buffer, ext: string): string {
|
||||
const file = join(tmpdir(), `vp_${randomBytes(8).toString("hex")}${ext}`);
|
||||
require("fs").writeFileSync(file, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
private async readFile(path: string): Promise<Buffer> {
|
||||
return require("fs").readFileSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreprocessedAudio {
|
||||
audio: Buffer;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
export interface AudioFeatures {
|
||||
mfccs: number[][];
|
||||
zeroCrossingRate: number;
|
||||
spectralCentroid: number;
|
||||
spectralRollOff: number;
|
||||
durationSec: number;
|
||||
}
|
||||
8
services/voiceprint/tsconfig.json
Normal file
8
services/voiceprint/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user