feat: improve Korean neural voice quality for v0.1.1

This commit is contained in:
oz
2026-10-14 07:15:07 +00:00
parent 1c0069c367
commit a92b2ba1e9
161 changed files with 13727 additions and 454 deletions
+73 -48
View File
@@ -2,44 +2,61 @@
import { createHash } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { INTRO, PATTERNS, WORDS } from "../src/content";
import { INTRO, PATTERNS, WORDS, renderSentence } 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+/);
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.1.1 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
expect.soft(manifest.version).toBe("0.1.1");
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("모든 문장과 안내에 대응하는 로컬 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),
})),
),
];
it("101개 전체 원문·SHA-256·bytes·duration과 24kHz mono PCM16 신호를 검증한다", () => {
expect(manifest.files.map(({ file, text }) => ({ file, text }))).toEqual(
expected,
);
expect(
readdirSync("public/audio").filter((f) => f.endsWith(".wav")),
).toHaveLength(101);
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}`);
@@ -47,26 +64,34 @@ describe("저장한 한국어 음성 자산", () => {
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(file.readUInt32LE(24)).toBe(16000);
expect.soft(file.readUInt32LE(24), entry.file).toBe(24000);
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(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);
// RMS 정규화 후 최고 난이도 아닌 가장 큰 +6dB에서도 클리핑 여유를 확인한다.
expect(
(values.reduce((peak, x) => Math.max(peak, Math.abs(x)), 0) / rms) *
0.03 *
2,
).toBeLessThan(0.8);
expect(peak).toBeLessThan(32767);
// 런타임 최대 +6dB와 RMS 정규화에서도 기존 클리핑 여유를 보존한다.
expect((peak / rms) * 0.03 * 2).toBeLessThan(0.8);
}
expect(size).toBeLessThan(20 * 1024 * 1024);
expect(size).toBeLessThan(48 * 1024 * 1024);
});
});