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