feat: add VoicePrint tRPC router with service, ML engine, and storage modules

- Create voiceprint router with 7 protected procedures:
  getEnrollments, createEnrollment, deleteEnrollment, analyzeAudio,
  getAnalyses, getAnalysisResult, getJobStatus
- Create voiceprint service with core business logic:
  enrollment CRUD, audio analysis pipeline, alert creation, batch jobs
- Create ML engine with placeholder implementations for
  audio preprocessing, synthetic detection, voice matching, embedding
- Create storage module for audio file persistence on disk
- Add valibot schemas for input validation
- Wire voiceprint router into root tRPC router
- Add comprehensive unit tests (33 tests, all passing)
This commit is contained in:
2026-05-25 16:28:43 -04:00
parent b2c3470a71
commit bec8cbf269
10 changed files with 1033 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { userRouter } from "./routers/user";
import { billingRouter } from "./routers/billing";
import { notificationRouter } from "./routers/notification";
import { darkwatchRouter } from "./routers/darkwatch";
import { voiceprintRouter } from "./routers/voiceprint";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
@@ -11,6 +12,7 @@ export const appRouter = createTRPCRouter({
billing: billingRouter,
notification: notificationRouter,
darkwatch: darkwatchRouter,
voiceprint: voiceprintRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { initTRPC, TRPCError } from "@trpc/server";
import { wrap } from "@typeschema/valibot";
import {
CreateEnrollmentSchema,
DeleteEnrollmentSchema,
AnalyzeAudioSchema,
AnalysisFilterSchema,
AnalysisResultSchema,
JobStatusSchema,
} from "../schemas/voiceprint";
vi.mock("~/server/services/voiceprint.service", () => ({
getEnrollments: vi.fn(),
createEnrollment: vi.fn(),
deleteEnrollment: vi.fn(),
analyzeAudio: vi.fn(),
getAnalyses: vi.fn(),
getAnalysisResult: vi.fn(),
getJobStatus: vi.fn(),
}));
import * as voiceprintService from "~/server/services/voiceprint.service";
const mockGetEnrollments = vi.mocked(voiceprintService.getEnrollments);
const mockCreateEnrollment = vi.mocked(voiceprintService.createEnrollment);
const mockDeleteEnrollment = vi.mocked(voiceprintService.deleteEnrollment);
const mockAnalyzeAudio = vi.mocked(voiceprintService.analyzeAudio);
const mockGetAnalyses = vi.mocked(voiceprintService.getAnalyses);
const mockGetAnalysisResult = vi.mocked(voiceprintService.getAnalysisResult);
const mockGetJobStatus = vi.mocked(voiceprintService.getJobStatus);
type User = {
id: string; email: string; name: string | null; image: string | null;
role: string; emailVerified: Date | null; deletedAt: Date | null;
stripeCustomerId: string | null;
createdAt: Date; updatedAt: Date;
};
type Ctx = { db: object; user: User | null; apiKey: string | null };
function createCaller(user: User | null) {
const t = initTRPC.context<Ctx>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.user } });
});
const router = t.router({
getEnrollments: t.procedure.use(isAuthed).query(async ({ ctx }) => {
return mockGetEnrollments(ctx.user.id);
}),
createEnrollment: t.procedure.use(isAuthed)
.input(wrap(CreateEnrollmentSchema))
.mutation(async ({ ctx, input }) => {
return mockCreateEnrollment(ctx.user.id, input.name, input.audioBase64);
}),
deleteEnrollment: t.procedure.use(isAuthed)
.input(wrap(DeleteEnrollmentSchema))
.mutation(async ({ ctx, input }) => {
return mockDeleteEnrollment(ctx.user.id, input.enrollmentId);
}),
analyzeAudio: t.procedure.use(isAuthed)
.input(wrap(AnalyzeAudioSchema))
.mutation(async ({ ctx, input }) => {
return mockAnalyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
}),
getAnalyses: t.procedure.use(isAuthed)
.input(wrap(AnalysisFilterSchema))
.query(async ({ ctx, input }) => {
return mockGetAnalyses(ctx.user.id, input);
}),
getAnalysisResult: t.procedure.use(isAuthed)
.input(wrap(AnalysisResultSchema))
.query(async ({ ctx, input }) => {
return mockGetAnalysisResult(ctx.user.id, input.analysisId);
}),
getJobStatus: t.procedure.use(isAuthed)
.input(wrap(JobStatusSchema))
.query(async ({ ctx, input }) => {
return mockGetJobStatus(ctx.user.id, input.jobId);
}),
});
const caller = t.createCallerFactory(router);
return caller({ db: {} as never, user, apiKey: null });
}
const baseUser: User = {
id: "user-1", email: "a@b.com", name: "Test", image: null,
role: "user", emailVerified: null, deletedAt: null,
stripeCustomerId: null,
createdAt: new Date(), updatedAt: new Date(),
};
function makeUser(overrides: Partial<User> = {}): User {
return { ...baseUser, ...overrides };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("voiceprint.getEnrollments", () => {
it("returns enrollments for authenticated user", async () => {
const items = [{ id: "enr-1", name: "My Voice" }];
mockGetEnrollments.mockResolvedValue(items as never);
const api = createCaller(makeUser());
expect(await api.getEnrollments()).toEqual(items);
});
it("rejects unauthenticated", async () => {
const api = createCaller(null);
await expect(api.getEnrollments()).rejects.toThrow(TRPCError);
});
});
describe("voiceprint.createEnrollment", () => {
it("creates an enrollment", async () => {
const enrollment = { id: "enr-1", name: "My Voice" };
mockCreateEnrollment.mockResolvedValue(enrollment as never);
const api = createCaller(makeUser());
const result = await api.createEnrollment({ name: "My Voice", audioBase64: "dGVzdA==" });
expect(result).toEqual(enrollment);
});
it("rejects empty name", async () => {
const api = createCaller(makeUser());
await expect(
api.createEnrollment({ name: "", audioBase64: "dGVzdA==" }),
).rejects.toThrow();
});
});
describe("voiceprint.deleteEnrollment", () => {
it("deletes enrollment", async () => {
mockDeleteEnrollment.mockResolvedValue({ id: "enr-1", isActive: false } as never);
const api = createCaller(makeUser());
const result = await api.deleteEnrollment({ enrollmentId: "enr-1" });
expect(result.isActive).toBe(false);
});
});
describe("voiceprint.analyzeAudio", () => {
it("analyzes audio and returns result", async () => {
const result = { id: "ana-1", verdict: "NATURAL", confidence: 0.95 };
mockAnalyzeAudio.mockResolvedValue(result as never);
const api = createCaller(makeUser());
const res = await api.analyzeAudio({ audioBase64: "dGVzdA==" });
expect(res.verdict).toBe("NATURAL");
});
it("accepts optional enrollmentId", async () => {
mockAnalyzeAudio.mockResolvedValue({ id: "ana-1" } as never);
const api = createCaller(makeUser());
await api.analyzeAudio({ audioBase64: "dGVzdA==", enrollmentId: "enr-1" });
expect(mockAnalyzeAudio).toHaveBeenCalledWith("user-1", "dGVzdA==", "enr-1");
});
});
describe("voiceprint.getAnalyses", () => {
it("returns paginated analyses", async () => {
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockGetAnalyses.mockResolvedValue(data);
const api = createCaller(makeUser());
const result = await api.getAnalyses({ page: 1, limit: 20 });
expect(result.total).toBe(0);
});
it("passes verdict filter", async () => {
mockGetAnalyses.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20, totalPages: 0 });
const api = createCaller(makeUser());
await api.getAnalyses({ verdict: "SYNTHETIC" });
expect(mockGetAnalyses).toHaveBeenCalledWith("user-1", { verdict: "SYNTHETIC", page: 1, limit: 20 });
});
});
describe("voiceprint.getAnalysisResult", () => {
it("returns analysis details", async () => {
const analysis = { id: "ana-1", verdict: "NATURAL", confidence: 0.95 };
mockGetAnalysisResult.mockResolvedValue(analysis as never);
const api = createCaller(makeUser());
const result = await api.getAnalysisResult({ analysisId: "ana-1" });
expect(result.id).toBe("ana-1");
});
});
describe("voiceprint.getJobStatus", () => {
it("returns job status", async () => {
const job = { id: "job-1", status: "RUNNING", result: null };
mockGetJobStatus.mockResolvedValue(job as never);
const api = createCaller(makeUser());
const result = await api.getJobStatus({ jobId: "job-1" });
expect(result.status).toBe("RUNNING");
});
});

View File

@@ -0,0 +1,53 @@
import { wrap } from "@typeschema/valibot";
import { createTRPCRouter, protectedProcedure } from "../utils";
import {
CreateEnrollmentSchema,
DeleteEnrollmentSchema,
AnalyzeAudioSchema,
AnalysisFilterSchema,
AnalysisResultSchema,
JobStatusSchema,
} from "../schemas/voiceprint";
import * as voiceprintService from "~/server/services/voiceprint.service";
export const voiceprintRouter = createTRPCRouter({
getEnrollments: protectedProcedure.query(async ({ ctx }) => {
return voiceprintService.getEnrollments(ctx.user.id);
}),
createEnrollment: protectedProcedure
.input(wrap(CreateEnrollmentSchema))
.mutation(async ({ ctx, input }) => {
return voiceprintService.createEnrollment(ctx.user.id, input.name, input.audioBase64);
}),
deleteEnrollment: protectedProcedure
.input(wrap(DeleteEnrollmentSchema))
.mutation(async ({ ctx, input }) => {
return voiceprintService.deleteEnrollment(ctx.user.id, input.enrollmentId);
}),
analyzeAudio: protectedProcedure
.input(wrap(AnalyzeAudioSchema))
.mutation(async ({ ctx, input }) => {
return voiceprintService.analyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
}),
getAnalyses: protectedProcedure
.input(wrap(AnalysisFilterSchema))
.query(async ({ ctx, input }) => {
return voiceprintService.getAnalyses(ctx.user.id, input);
}),
getAnalysisResult: protectedProcedure
.input(wrap(AnalysisResultSchema))
.query(async ({ ctx, input }) => {
return voiceprintService.getAnalysisResult(ctx.user.id, input.analysisId);
}),
getJobStatus: protectedProcedure
.input(wrap(JobStatusSchema))
.query(async ({ ctx, input }) => {
return voiceprintService.getJobStatus(ctx.user.id, input.jobId);
}),
});

View File

@@ -0,0 +1,29 @@
import { object, string, minLength, optional, number, picklist } from "valibot";
export const CreateEnrollmentSchema = object({
name: string([minLength(1)]),
audioBase64: string([minLength(1)]),
});
export const DeleteEnrollmentSchema = object({
enrollmentId: string([minLength(1)]),
});
export const AnalyzeAudioSchema = object({
audioBase64: string([minLength(1)]),
enrollmentId: optional(string()),
});
export const AnalysisFilterSchema = object({
page: optional(number(), 1),
limit: optional(number(), 20),
verdict: optional(picklist(["NATURAL", "SYNTHETIC", "UNCERTAIN"])),
});
export const AnalysisResultSchema = object({
analysisId: string([minLength(1)]),
});
export const JobStatusSchema = object({
jobId: string([minLength(1)]),
});

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
const mockQueryResult = vi.fn().mockResolvedValue([]);
function createChain(initialPromise?: any): any {
const p = typeof initialPromise !== "undefined" ? initialPromise : mockQueryResult();
return new Proxy(p, {
get(target, prop) {
if (prop === "then" || prop === "catch" || prop === "finally") {
return Reflect.get(target, prop).bind(target);
}
return () => createChain(p);
},
});
}
vi.mock("~/server/db", () => ({
db: {
select: vi.fn(() => createChain()),
insert: vi.fn(() => createChain()),
update: vi.fn(() => createChain()),
},
}));
vi.mock("./voiceprint/storage", () => ({
saveAudio: vi.fn(),
getAudioUrl: vi.fn(),
deleteFile: vi.fn(),
computeHash: vi.fn(),
deleteAudio: vi.fn(),
}));
vi.mock("./voiceprint/ml.engine", () => ({
preprocessAudio: vi.fn(),
detectSynthetic: vi.fn(),
matchVoice: vi.fn(),
generateEmbedding: vi.fn(),
}));
const storage = await import("./voiceprint/storage");
const ml = await import("./voiceprint/ml.engine");
const mockEnrollment = {
id: "enr-1",
userId: "user-1",
name: "My Voice",
voiceHash: "hash-123",
audioMetadata: { filePath: "/some/path.wav", duration: 2.5, sampleRate: 16000 },
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockAnalysis = {
id: "ana-1",
enrollmentId: null,
userId: "user-1",
audioHash: "audio-hash-1",
isSynthetic: false,
confidence: 0.95,
analysisResult: { verdict: "NATURAL", score: 0.05, matchedSimilarity: null },
audioUrl: "/uploads/voiceprint/user-1/audio-hash-1.wav",
createdAt: new Date(),
};
const mockJob = {
id: "job-1",
userId: "user-1",
analysisType: "BATCH",
audioFilePath: "/path/to/audio.wav",
status: "PENDING",
errorMessage: null,
completedAt: null,
createdAt: new Date(),
};
const mockResult = {
id: "res-1",
analysisJobId: "job-1",
syntheticScore: 0.1,
verdict: "NATURAL",
confidence: 0.95,
processingTimeMs: 1500,
matchedEnrollmentId: null,
matchedSimilarity: null,
modelVersion: "v1",
createdAt: new Date(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("getEnrollments", () => {
it("returns enrollments for the user", async () => {
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
const { getEnrollments } = await import("./voiceprint.service");
const result = await getEnrollments("user-1");
expect(result).toEqual([mockEnrollment]);
});
});
describe("createEnrollment", () => {
it("saves audio and creates a DB record", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash",
filePath: "/path/file.wav",
});
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
});
vi.mocked(ml.generateEmbedding).mockResolvedValue({
vector: new Float64Array(256), hash: "embed-hash",
});
mockQueryResult.mockResolvedValueOnce([mockEnrollment]);
const { createEnrollment } = await import("./voiceprint.service");
const result = await createEnrollment("user-1", "My Voice", "dGVzdC1hdWRpbw==");
expect(result).toEqual(mockEnrollment);
expect(storage.saveAudio).toHaveBeenCalledWith("user-1", Buffer.from("test-audio"));
expect(ml.generateEmbedding).toHaveBeenCalled();
});
});
describe("deleteEnrollment", () => {
it("soft deletes enrollment and removes audio file", async () => {
mockQueryResult
.mockResolvedValueOnce([mockEnrollment])
.mockResolvedValueOnce([{ ...mockEnrollment, isActive: false }]);
const { deleteEnrollment } = await import("./voiceprint.service");
const result = await deleteEnrollment("user-1", "enr-1");
expect(result.isActive).toBe(false);
expect(storage.deleteFile).toHaveBeenCalledWith("/some/path.wav");
});
it("throws NOT_FOUND if enrollment does not belong to user", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { deleteEnrollment } = await import("./voiceprint.service");
await expect(deleteEnrollment("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
});
describe("analyzeAudio", () => {
it("returns verdict and confidence for analysis", async () => {
vi.mocked(storage.saveAudio).mockResolvedValue({
hash: "audio-hash", filePath: "/path/file.wav",
});
vi.mocked(storage.getAudioUrl).mockReturnValue("/uploads/voiceprint/user-1/audio-hash.wav");
vi.mocked(ml.preprocessAudio).mockResolvedValue({
duration: 2.5, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test"),
});
vi.mocked(ml.detectSynthetic).mockResolvedValue({
isSynthetic: false, confidence: 0.95, score: 0.05,
});
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
const { analyzeAudio } = await import("./voiceprint.service");
const result = await analyzeAudio("user-1", "dGVzdC1hdWRpbw==");
expect(result.verdict).toBe("NATURAL");
expect(result.confidence).toBe(0.95);
expect(result.isSynthetic).toBe(false);
expect(result.score).toBe(0.05);
});
});
describe("getAnalyses", () => {
it("returns paginated analyses", async () => {
mockQueryResult
.mockResolvedValueOnce([{ count: 1 }])
.mockResolvedValueOnce([mockAnalysis]);
const { getAnalyses } = await import("./voiceprint.service");
const result = await getAnalyses("user-1", { page: 1, limit: 10 });
expect(result.items).toEqual([mockAnalysis]);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
});
describe("getAnalysisResult", () => {
it("returns detailed analysis", async () => {
mockQueryResult.mockResolvedValueOnce([mockAnalysis]);
const { getAnalysisResult } = await import("./voiceprint.service");
const result = await getAnalysisResult("user-1", "ana-1");
expect(result).toEqual(mockAnalysis);
});
it("throws NOT_FOUND for non-existent analysis", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { getAnalysisResult } = await import("./voiceprint.service");
await expect(getAnalysisResult("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
});
describe("getJobStatus", () => {
it("returns job status with result when completed", async () => {
const completedJob = { ...mockJob, status: "COMPLETED" };
mockQueryResult
.mockResolvedValueOnce([completedJob])
.mockResolvedValueOnce([mockResult]);
const { getJobStatus } = await import("./voiceprint.service");
const result = await getJobStatus("user-1", "job-1");
expect(result.status).toBe("COMPLETED");
expect(result.result).toEqual(mockResult);
});
it("throws NOT_FOUND for non-existent job", async () => {
mockQueryResult.mockResolvedValueOnce([]);
const { getJobStatus } = await import("./voiceprint.service");
await expect(getJobStatus("user-1", "nonexistent")).rejects.toThrow(TRPCError);
});
});
describe("createBatchJob", () => {
it("creates a batch analysis job", async () => {
mockQueryResult.mockResolvedValueOnce([mockJob]);
const { createBatchJob } = await import("./voiceprint.service");
const result = await createBatchJob("user-1", "/path/to/audio.wav");
expect(result.id).toBe("job-1");
expect(result.analysisType).toBe("BATCH");
expect(result.status).toBe("PENDING");
});
});

View File

@@ -0,0 +1,282 @@
import { TRPCError } from "@trpc/server";
import { eq, and, desc, count } from "drizzle-orm";
import { db } from "~/server/db";
import {
voiceEnrollments,
voiceAnalyses,
analysisJobs,
analysisResults,
subscriptions,
normalizedAlerts,
} from "~/server/db/schema";
import { saveAudio, getAudioUrl, deleteFile } from "./voiceprint/storage";
import {
preprocessAudio,
detectSynthetic,
matchVoice,
generateEmbedding,
} from "./voiceprint/ml.engine";
type DetectionVerdict = "NATURAL" | "SYNTHETIC" | "UNCERTAIN";
interface AnalysisFilters {
page?: number;
limit?: number;
verdict?: DetectionVerdict;
}
export async function getEnrollments(userId: string) {
return db
.select()
.from(voiceEnrollments)
.where(and(eq(voiceEnrollments.userId, userId), eq(voiceEnrollments.isActive, true)))
.orderBy(desc(voiceEnrollments.createdAt));
}
export async function createEnrollment(userId: string, name: string, audioBase64: string) {
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash, filePath } = await saveAudio(userId, audioBuffer);
const features = await preprocessAudio(audioBuffer);
const embedding = await generateEmbedding(features);
const [enrollment] = await db
.insert(voiceEnrollments)
.values({
userId,
name,
voiceHash: embedding.hash,
audioMetadata: { filePath, duration: features.duration, sampleRate: features.sampleRate },
})
.returning();
return enrollment;
}
export async function deleteEnrollment(userId: string, enrollmentId: string) {
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(and(eq(voiceEnrollments.id, enrollmentId), eq(voiceEnrollments.userId, userId)))
.limit(1);
if (!enrollment) {
throw new TRPCError({ code: "NOT_FOUND", message: "Enrollment not found" });
}
const metadata = enrollment.audioMetadata as { filePath?: string } | null;
if (metadata?.filePath) {
await deleteFile(metadata.filePath);
}
const [deleted] = await db
.update(voiceEnrollments)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(voiceEnrollments.id, enrollmentId))
.returning();
return deleted;
}
function deriveVerdict(isSynthetic: boolean, confidence: number): DetectionVerdict {
if (confidence >= 0.7) {
return isSynthetic ? "SYNTHETIC" : "NATURAL";
}
return "UNCERTAIN";
}
async function createVoiceAlert(
userId: string,
analysisId: string,
verdict: DetectionVerdict,
confidence: number,
) {
try {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, userId))
.limit(1);
if (sub) {
const category = verdict === "SYNTHETIC" ? "SYNTHETIC_VOICE" : "VOICE_MISMATCH";
await db.insert(normalizedAlerts).values({
source: "VOICEPRINT",
category,
severity: verdict === "SYNTHETIC" ? "HIGH" : "MEDIUM",
userId,
title: verdict === "SYNTHETIC" ? "Synthetic Voice Detected" : "Voice Mismatch Detected",
description: `Analysis ${analysisId} returned verdict ${verdict} with ${(confidence * 100).toFixed(1)}% confidence`,
entities: { analysisId, verdict, confidence },
sourceAlertId: `voiceprint-${analysisId}`,
createdAt: new Date(),
});
}
} catch (err) {
console.error("[voiceprint] Failed to create alert:", err);
}
}
export async function analyzeAudio(
userId: string,
audioBase64: string,
enrollmentId?: string,
) {
const audioBuffer = Buffer.from(audioBase64, "base64");
const { hash: audioHash, filePath } = await saveAudio(userId, audioBuffer);
const features = await preprocessAudio(audioBuffer);
const detection = await detectSynthetic(features);
const audioUrl = getAudioUrl(userId, audioHash);
let matchedEnrollmentId: string | null = null;
let matchedSimilarity: number | null = null;
if (enrollmentId) {
const [enrollment] = await db
.select()
.from(voiceEnrollments)
.where(
and(
eq(voiceEnrollments.id, enrollmentId),
eq(voiceEnrollments.userId, userId),
eq(voiceEnrollments.isActive, true),
),
)
.limit(1);
if (enrollment) {
const embedding = await generateEmbedding(features);
const match = await matchVoice(embedding, enrollmentId);
matchedEnrollmentId = enrollmentId;
matchedSimilarity = match.similarity;
}
}
const isSynthetic = detection.isSynthetic;
const confidence = detection.confidence;
const verdict = deriveVerdict(isSynthetic, confidence);
const [analysis] = await db
.insert(voiceAnalyses)
.values({
enrollmentId: matchedEnrollmentId ?? undefined,
userId,
audioHash,
isSynthetic,
confidence,
analysisResult: { verdict, score: detection.score, matchedSimilarity },
audioUrl,
})
.returning();
if (verdict === "SYNTHETIC" || matchedSimilarity !== null) {
await createVoiceAlert(userId, analysis.id, verdict, confidence);
}
return {
id: analysis.id,
verdict,
confidence,
isSynthetic,
score: detection.score,
matchedEnrollmentId,
matchedSimilarity,
audioUrl,
createdAt: analysis.createdAt,
};
}
export async function getAnalyses(
userId: string,
filters?: AnalysisFilters,
) {
const page = filters?.page ?? 1;
const limit = filters?.limit ?? 20;
const offset = (page - 1) * limit;
const conditions = [eq(voiceAnalyses.userId, userId)];
if (filters?.verdict) {
if (filters.verdict === "SYNTHETIC") {
conditions.push(eq(voiceAnalyses.isSynthetic, true));
} else if (filters.verdict === "NATURAL") {
conditions.push(eq(voiceAnalyses.isSynthetic, false));
}
}
const [totalResult] = await db
.select({ count: count() })
.from(voiceAnalyses)
.where(and(...conditions));
const items = await db
.select()
.from(voiceAnalyses)
.where(and(...conditions))
.orderBy(desc(voiceAnalyses.createdAt))
.limit(limit)
.offset(offset);
return {
items,
total: totalResult.count,
page,
limit,
totalPages: Math.ceil(totalResult.count / limit),
};
}
export async function getAnalysisResult(userId: string, analysisId: string) {
const [analysis] = await db
.select()
.from(voiceAnalyses)
.where(and(eq(voiceAnalyses.id, analysisId), eq(voiceAnalyses.userId, userId)))
.limit(1);
if (!analysis) {
throw new TRPCError({ code: "NOT_FOUND", message: "Analysis not found" });
}
return analysis;
}
export async function getJobStatus(userId: string, jobId: string) {
const [job] = await db
.select()
.from(analysisJobs)
.where(and(eq(analysisJobs.id, jobId), eq(analysisJobs.userId, userId)))
.limit(1);
if (!job) {
throw new TRPCError({ code: "NOT_FOUND", message: "Job not found" });
}
let result = null;
if (job.status === "COMPLETED") {
const [r] = await db
.select()
.from(analysisResults)
.where(eq(analysisResults.analysisJobId, jobId))
.limit(1);
result = r ?? null;
}
return {
...job,
result,
};
}
export async function createBatchJob(userId: string, audioFilePath: string) {
const [job] = await db
.insert(analysisJobs)
.values({
userId,
analysisType: "BATCH",
audioFilePath,
status: "PENDING",
})
.returning();
return job;
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
describe("voiceprint ML engine", () => {
describe("preprocessAudio", () => {
it("returns default audio features", async () => {
const { preprocessAudio } = await import("./ml.engine");
const buffer = Buffer.from("fake-audio-data");
const result = await preprocessAudio(buffer);
expect(result.duration).toBe(0);
expect(result.sampleRate).toBe(16000);
expect(result.channels).toBe(1);
expect(result.rawPcm).toEqual(buffer);
});
});
describe("detectSynthetic", () => {
it("returns default detection result", async () => {
const { detectSynthetic } = await import("./ml.engine");
const features = { duration: 0, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test") };
const result = await detectSynthetic(features);
expect(result.isSynthetic).toBe(false);
expect(result.confidence).toBe(1.0);
expect(result.score).toBe(0.0);
});
});
describe("matchVoice", () => {
it("returns default match result", async () => {
const { matchVoice } = await import("./ml.engine");
const embedding = { vector: new Float64Array(256), hash: "test-hash" };
const result = await matchVoice(embedding, "enrollment-1");
expect(result.similarity).toBe(0);
expect(result.matched).toBe(false);
});
});
describe("generateEmbedding", () => {
it("returns an embedding with hash from the audio buffer", async () => {
const { generateEmbedding } = await import("./ml.engine");
const features = { duration: 1, sampleRate: 16000, channels: 1, rawPcm: Buffer.from("test-audio") };
const result = await generateEmbedding(features);
expect(result.vector.length).toBe(256);
expect(result.hash).toBeTruthy();
expect(typeof result.hash).toBe("string");
});
});
});

View File

@@ -0,0 +1,56 @@
export interface AudioFeatures {
duration: number;
sampleRate: number;
channels: number;
rawPcm: Buffer;
}
export interface SyntheticDetectionResult {
isSynthetic: boolean;
confidence: number;
score: number;
}
export interface VoiceMatchResult {
similarity: number;
matched: boolean;
}
export interface Embedding {
vector: Float64Array;
hash: string;
}
export async function preprocessAudio(audioBuffer: Buffer): Promise<AudioFeatures> {
return {
duration: 0,
sampleRate: 16000,
channels: 1,
rawPcm: audioBuffer,
};
}
export async function detectSynthetic(features: AudioFeatures): Promise<SyntheticDetectionResult> {
return {
isSynthetic: false,
confidence: 1.0,
score: 0.0,
};
}
export async function matchVoice(embedding: Embedding, enrollmentId: string): Promise<VoiceMatchResult> {
return {
similarity: 0,
matched: false,
};
}
export async function generateEmbedding(features: AudioFeatures): Promise<Embedding> {
const { createHash } = await import("node:crypto");
const hash = createHash("sha256").update(features.rawPcm).digest("hex");
return {
vector: new Float64Array(256),
hash,
};
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, existsSync, unlinkSync, rmSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("voiceprint storage", () => {
let testDir: string;
let userId: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "vp-storage-test-"));
userId = "test-user-123";
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});
afterEach(() => {
vi.restoreAllMocks();
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
describe("computeHash", () => {
it("returns a SHA-256 hex string", async () => {
const { computeHash } = await import("./storage");
const hash = computeHash(Buffer.from("test-audio"));
expect(hash).toBeTruthy();
expect(hash.length).toBe(64);
});
});
describe("saveAudio", () => {
it("creates directory and saves file", async () => {
const { saveAudio } = await import("./storage");
const audioBuffer = Buffer.from("test-audio-content");
const result = await saveAudio(userId, audioBuffer);
expect(result.hash).toBeTruthy();
expect(result.hash.length).toBe(64);
expect(result.filePath).toContain(userId);
expect(existsSync(result.filePath)).toBe(true);
});
it("reuses existing directory", async () => {
const { saveAudio } = await import("./storage");
await saveAudio(userId, Buffer.from("audio-1"));
await saveAudio(userId, Buffer.from("audio-2"));
const dir = join(testDir, "uploads", "voiceprint", userId);
expect(existsSync(dir)).toBe(true);
});
});
describe("getAudioUrl", () => {
it("returns the audio URL path", async () => {
const { getAudioUrl } = await import("./storage");
const url = getAudioUrl("user-1", "some-hash");
expect(url).toBe("/uploads/voiceprint/user-1/some-hash.wav");
});
});
describe("deleteFile", () => {
it("deletes a file", async () => {
const filePath = join(testDir, "test.wav");
await writeFile(filePath, Buffer.from("test"));
expect(existsSync(filePath)).toBe(true);
const { deleteFile } = await import("./storage");
await deleteFile(filePath);
expect(existsSync(filePath)).toBe(false);
});
it("does not throw if file does not exist", async () => {
const { deleteFile } = await import("./storage");
await expect(deleteFile("/nonexistent.wav")).resolves.toBeUndefined();
});
});
describe("deleteAudio", () => {
it("constructs path and deletes the file", async () => {
const { saveAudio, deleteAudio } = await import("./storage");
const { hash } = await saveAudio(userId, Buffer.from("test-audio"));
const filePath = join(testDir, "uploads", "voiceprint", userId, `${hash}.wav`);
expect(existsSync(filePath)).toBe(true);
await deleteAudio(userId, hash);
expect(existsSync(filePath)).toBe(false);
});
});
});

View File

@@ -0,0 +1,43 @@
import { createHash } from "node:crypto";
import { writeFile, unlink, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
export function computeHash(audioBuffer: Buffer): string {
return createHash("sha256").update(audioBuffer).digest("hex");
}
function getUserDir(userId: string): string {
return join(process.cwd(), "uploads", "voiceprint", userId);
}
export async function saveAudio(
userId: string,
audioBuffer: Buffer,
): Promise<{ hash: string; filePath: string }> {
const hash = computeHash(audioBuffer);
const userDir = getUserDir(userId);
if (!existsSync(userDir)) {
await mkdir(userDir, { recursive: true });
}
const filePath = join(userDir, `${hash}.wav`);
await writeFile(filePath, audioBuffer);
return { hash, filePath };
}
export function getAudioUrl(userId: string, audioHash: string): string {
return `/uploads/voiceprint/${userId}/${audioHash}.wav`;
}
export async function deleteFile(filePath: string): Promise<void> {
try {
await unlink(filePath);
} catch {
// File may not exist
}
}
export async function deleteAudio(userId: string, audioHash: string): Promise<void> {
const filePath = join(getUserDir(userId), `${audioHash}.wav`);
await deleteFile(filePath);
}