// @vitest-environment node import { createHash } from "node:crypto"; import { readFileSync, readdirSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { INTRO, PATTERNS, WORDS } from "../src/content"; // 생성 바이너리를 눈으로 검토할 수 없으므로 문장·해시·PCM 포맷·실제 신호를 // 함께 확인한다. manifest는 생성기가 남기며 100개 조합과 안내를 빠짐없이 묶는다. describe("저장한 한국어 음성 자산", () => { it("실제 생성 도구 버전을 기록하여 재생성의 출처를 추적한다", () => { const manifest = JSON.parse( readFileSync("public/audio/manifest.json", "utf8"), ) as { toolVersions?: { espeak: string; ffmpeg: string } }; expect(manifest.toolVersions?.espeak).toMatch(/eSpeak NG.*\d+\.\d+/); expect(manifest.toolVersions?.ffmpeg).toMatch(/ffmpeg version \d+\.\d+/); }); it("모든 문장과 안내에 대응하는 로컬 WAV·출처·해시를 제공한다", () => { const manifest = JSON.parse( readFileSync("public/audio/manifest.json", "utf8"), ) as { generator: string; files: { file: string; text: string; sha256: string }[]; }; expect(manifest.generator).toContain("eSpeak NG"); const expected = [ { file: "intro.wav", text: INTRO }, ...PATTERNS.flatMap((p) => WORDS.map((w) => ({ file: `${p.id}-${w.id}.wav`, text: p.sentence .replaceAll("{word}", w.answer) .replaceAll("{distractor1}", w.distractors[0]!.word) .replaceAll("{distractor2}", w.distractors[1]!.word), })), ), ]; expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual( expected, ); expect( readdirSync("public/audio").filter((f) => f.endsWith(".wav")), ).toHaveLength(101); let size = 0; for (const entry of manifest.files) { const file = readFileSync(`public/audio/${entry.file}`); size += file.length; expect(createHash("sha256").update(file).digest("hex")).toBe( entry.sha256, ); expect(file.toString("ascii", 0, 4)).toBe("RIFF"); expect(file.toString("ascii", 8, 12)).toBe("WAVE"); expect(file.readUInt16LE(22)).toBe(1); expect(file.readUInt32LE(24)).toBe(16000); expect(file.readUInt16LE(34)).toBe(16); expect(file.length).toBeGreaterThan(16000); const values = Array.from({ length: (file.length - 44) / 2 }, (_, i) => file.readInt16LE(44 + i * 2), ); const rms = Math.sqrt( values.reduce((sum, x) => sum + x * x, 0) / values.length, ); expect(rms).toBeGreaterThan(100); // RMS 정규화 후 최고 난이도 아닌 가장 큰 +6dB에서도 클리핑 여유를 확인한다. expect( (values.reduce((peak, x) => Math.max(peak, Math.abs(x)), 0) / rms) * 0.03 * 2, ).toBeLessThan(0.8); } expect(size).toBeLessThan(20 * 1024 * 1024); }); });