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)]),
|
||||
});
|
||||
Reference in New Issue
Block a user