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:
@@ -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;
|
||||
|
||||
195
web/src/server/api/routers/voiceprint.test.ts
Normal file
195
web/src/server/api/routers/voiceprint.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
web/src/server/api/routers/voiceprint.ts
Normal file
53
web/src/server/api/routers/voiceprint.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
29
web/src/server/api/schemas/voiceprint.ts
Normal file
29
web/src/server/api/schemas/voiceprint.ts
Normal 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)]),
|
||||
});
|
||||
232
web/src/server/services/voiceprint.service.test.ts
Normal file
232
web/src/server/services/voiceprint.service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
282
web/src/server/services/voiceprint.service.ts
Normal file
282
web/src/server/services/voiceprint.service.ts
Normal 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;
|
||||
}
|
||||
47
web/src/server/services/voiceprint/ml.engine.test.ts
Normal file
47
web/src/server/services/voiceprint/ml.engine.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
56
web/src/server/services/voiceprint/ml.engine.ts
Normal file
56
web/src/server/services/voiceprint/ml.engine.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
94
web/src/server/services/voiceprint/storage.test.ts
Normal file
94
web/src/server/services/voiceprint/storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
web/src/server/services/voiceprint/storage.ts
Normal file
43
web/src/server/services/voiceprint/storage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user