98 lines
3.7 KiB
TypeScript
98 lines
3.7 KiB
TypeScript
// @vitest-environment node
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import { INTRO, PATTERNS, WORDS, renderSentence } from "../src/content";
|
|
|
|
const manifest = JSON.parse(
|
|
readFileSync("public/audio/manifest.json", "utf8"),
|
|
) as {
|
|
version: string;
|
|
provider: string;
|
|
voice: string;
|
|
rate: string;
|
|
pitch: string;
|
|
sampleRate: number;
|
|
toolVersions: { "edge-tts"?: string; ffmpeg?: string };
|
|
files: {
|
|
file: string;
|
|
text: string;
|
|
sha256: string;
|
|
bytes: number;
|
|
duration: number;
|
|
}[];
|
|
};
|
|
const expected = [
|
|
{ file: "intro.wav", text: INTRO },
|
|
...PATTERNS.flatMap((p) =>
|
|
WORDS.map((w) => ({
|
|
file: `${p.id}-${w.id}.wav`,
|
|
text: renderSentence(p, w),
|
|
})),
|
|
),
|
|
];
|
|
|
|
// 생성기의 검증 함수를 재사용하지 않고 실제 WAV 바이트를 직접 읽는다.
|
|
// 문장·해시·헤더·신호를 묶어 manifest만 갱신하는 거짓 업그레이드를 막는다.
|
|
describe("저장한 한국어 neural 음성 자산", () => {
|
|
it("0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
|
|
expect.soft(manifest.version).toBe("0.2.5");
|
|
expect.soft(manifest.provider).toBe("Microsoft Edge online TTS");
|
|
expect.soft(manifest.voice).toBe("ko-KR-HyunsuMultilingualNeural");
|
|
expect.soft(manifest.rate).toBe("-6%");
|
|
expect.soft(manifest.pitch).toBe("-4Hz");
|
|
expect.soft(manifest.sampleRate).toBe(24000);
|
|
expect.soft(manifest.toolVersions["edge-tts"]).toBe("7.3.1");
|
|
expect
|
|
.soft(manifest.toolVersions.ffmpeg)
|
|
.toMatch(/^ffmpeg version \d+\.\d+/);
|
|
expect(JSON.stringify(manifest)).not.toMatch(
|
|
/espeak|cookie|authorization|trustedclienttoken/i,
|
|
);
|
|
});
|
|
it("101개 전체 원문·SHA-256·bytes·duration과 24kHz mono PCM16 신호를 검증한다", () => {
|
|
expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual(
|
|
expected,
|
|
);
|
|
expect(readdirSync("public/audio").sort()).toEqual(
|
|
[...expected.map((e) => e.file), "manifest.json"].sort(),
|
|
);
|
|
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.length).toBe(entry.bytes);
|
|
expect(file.toString("ascii", 0, 4)).toBe("RIFF");
|
|
expect(file.toString("ascii", 8, 12)).toBe("WAVE");
|
|
expect(file.toString("ascii", 12, 16)).toBe("fmt ");
|
|
expect(file.readUInt32LE(16)).toBe(16);
|
|
expect(file.readUInt16LE(20)).toBe(1);
|
|
expect(file.readUInt16LE(22)).toBe(1);
|
|
expect.soft(file.readUInt32LE(24), entry.file).toBe(24000);
|
|
expect(file.readUInt16LE(34)).toBe(16);
|
|
expect(file.toString("ascii", 36, 40)).toBe("data");
|
|
expect(file.readUInt32LE(40)).toBe(file.length - 44);
|
|
const duration = (file.length - 44) / 2 / file.readUInt32LE(24);
|
|
expect.soft(entry.duration, entry.file).toBe(duration);
|
|
expect(duration).toBeGreaterThanOrEqual(1);
|
|
expect(duration).toBeLessThanOrEqual(20);
|
|
let sum = 0,
|
|
peak = 0;
|
|
for (let offset = 44; offset < file.length; offset += 2) {
|
|
const value = file.readInt16LE(offset);
|
|
sum += value * value;
|
|
peak = Math.max(peak, Math.abs(value));
|
|
}
|
|
const rms = Math.sqrt(sum / ((file.length - 44) / 2));
|
|
expect(rms).toBeGreaterThan(100);
|
|
expect(peak).toBeLessThan(32767);
|
|
// 런타임 최대 +6dB와 RMS 정규화에서도 기존 클리핑 여유를 보존한다.
|
|
expect((peak / rms) * 0.03 * 2).toBeLessThan(0.8);
|
|
}
|
|
expect(size).toBeLessThan(48 * 1024 * 1024);
|
|
});
|
|
});
|