diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index 909e3dd..f0738ac 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -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; diff --git a/web/src/server/api/routers/voiceprint.test.ts b/web/src/server/api/routers/voiceprint.test.ts new file mode 100644 index 0000000..d77e1f2 --- /dev/null +++ b/web/src/server/api/routers/voiceprint.test.ts @@ -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().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 { + 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"); + }); +}); diff --git a/web/src/server/api/routers/voiceprint.ts b/web/src/server/api/routers/voiceprint.ts new file mode 100644 index 0000000..98587ba --- /dev/null +++ b/web/src/server/api/routers/voiceprint.ts @@ -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); + }), +}); diff --git a/web/src/server/api/schemas/voiceprint.ts b/web/src/server/api/schemas/voiceprint.ts new file mode 100644 index 0000000..51ff634 --- /dev/null +++ b/web/src/server/api/schemas/voiceprint.ts @@ -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)]), +}); diff --git a/web/src/server/services/voiceprint.service.test.ts b/web/src/server/services/voiceprint.service.test.ts new file mode 100644 index 0000000..396dfa5 --- /dev/null +++ b/web/src/server/services/voiceprint.service.test.ts @@ -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"); + }); +}); diff --git a/web/src/server/services/voiceprint.service.ts b/web/src/server/services/voiceprint.service.ts new file mode 100644 index 0000000..14cc66f --- /dev/null +++ b/web/src/server/services/voiceprint.service.ts @@ -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; +} diff --git a/web/src/server/services/voiceprint/ml.engine.test.ts b/web/src/server/services/voiceprint/ml.engine.test.ts new file mode 100644 index 0000000..7b43d33 --- /dev/null +++ b/web/src/server/services/voiceprint/ml.engine.test.ts @@ -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"); + }); + }); +}); diff --git a/web/src/server/services/voiceprint/ml.engine.ts b/web/src/server/services/voiceprint/ml.engine.ts new file mode 100644 index 0000000..6316f75 --- /dev/null +++ b/web/src/server/services/voiceprint/ml.engine.ts @@ -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 { + return { + duration: 0, + sampleRate: 16000, + channels: 1, + rawPcm: audioBuffer, + }; +} + +export async function detectSynthetic(features: AudioFeatures): Promise { + return { + isSynthetic: false, + confidence: 1.0, + score: 0.0, + }; +} + +export async function matchVoice(embedding: Embedding, enrollmentId: string): Promise { + return { + similarity: 0, + matched: false, + }; +} + +export async function generateEmbedding(features: AudioFeatures): Promise { + const { createHash } = await import("node:crypto"); + const hash = createHash("sha256").update(features.rawPcm).digest("hex"); + + return { + vector: new Float64Array(256), + hash, + }; +} diff --git a/web/src/server/services/voiceprint/storage.test.ts b/web/src/server/services/voiceprint/storage.test.ts new file mode 100644 index 0000000..bc9153f --- /dev/null +++ b/web/src/server/services/voiceprint/storage.test.ts @@ -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); + }); + }); +}); diff --git a/web/src/server/services/voiceprint/storage.ts b/web/src/server/services/voiceprint/storage.ts new file mode 100644 index 0000000..c4e7483 --- /dev/null +++ b/web/src/server/services/voiceprint/storage.ts @@ -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 { + try { + await unlink(filePath); + } catch { + // File may not exist + } +} + +export async function deleteAudio(userId: string, audioHash: string): Promise { + const filePath = join(getUserDir(userId), `${audioHash}.wav`); + await deleteFile(filePath); +}