feat: improve Korean neural voice quality for v0.1.1
This commit is contained in:
+1
-1
@@ -201,7 +201,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.1")).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.1.1")).toBeInTheDocument();
|
||||
expect(f.audio.play).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
|
||||
+73
-48
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-26
@@ -13,10 +13,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().startsWith("http://127.0.0.1:4173"))
|
||||
external.push(request.url());
|
||||
if (
|
||||
request.url().endsWith(".wav") &&
|
||||
!request.url().endsWith("intro.wav")
|
||||
)
|
||||
if (request.url().endsWith(".wav") && !request.url().endsWith("intro.wav"))
|
||||
audioPath = new URL(request.url()).pathname;
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
@@ -32,19 +29,13 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-home.png`,
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-home.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /듣는 중/ }),
|
||||
).toBeDisabled();
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
"animation-name",
|
||||
"none",
|
||||
@@ -76,12 +67,10 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-game.png`,
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-game.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect(
|
||||
(await new AxeBuilder({ page }).analyze()).violations,
|
||||
).toEqual([]);
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.getByRole("button", { name: /다시 듣기/ }).click();
|
||||
await expect(target).toBeVisible();
|
||||
}
|
||||
@@ -110,12 +99,10 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-results.png`,
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-results.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual(
|
||||
[],
|
||||
);
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||
await page.getByRole("button", { name: "다시 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
@@ -129,15 +116,13 @@ test("오디오 HTTP 실패를 화면에 표시하고 재시도한다", async ({
|
||||
route.fulfill({ status: 404, body: "missing" }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
await page.unroute("**/audio/*.wav");
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
// 재시도는 안내부터 재생한다. 최대 8.1205 + 10.1725 + 시작·전환 0.4초에
|
||||
// 재시도는 안내부터 재생한다. v0.1.1 최대 7.056 + 11.256 + 시작·전환 0.4초에
|
||||
// 로드·디코드·병렬 브라우저 스케줄링 여유를 더해 이 assertion만 30초로 제한한다.
|
||||
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
+17
-29
@@ -19,9 +19,7 @@ test("전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된
|
||||
.soft(control.y + control.height)
|
||||
.toBeLessThanOrEqual(bounds.y + bounds.height);
|
||||
};
|
||||
await expectInside(
|
||||
page.getByRole("region", { name: "나의 소리 탐험 준비" }),
|
||||
);
|
||||
await expectInside(page.getByRole("region", { name: "나의 소리 탐험 준비" }));
|
||||
const start = page.getByRole("button", {
|
||||
name: "게임 시작 · 소리 듣기",
|
||||
});
|
||||
@@ -53,14 +51,10 @@ test("전체 음량은 설정 흐름 안에 들어가며 키보드로 조절된
|
||||
|
||||
test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
// DOM에만 설명을 남기고 모바일 CSS로 숨기면 컴포넌트 검증은 통과하므로
|
||||
// 실제 화면에서 난이도 배지와 쉬운 설명을 모두 읽을 수 있는지 확인한다.
|
||||
await expect
|
||||
.soft(page.locator(".db-note"))
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
await expect.soft(page.locator(".db-note")).toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.getByText("1단계 · SNR +6 dB"))
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
@@ -86,17 +80,13 @@ test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async
|
||||
})),
|
||||
);
|
||||
const homeHeadings = await wordBreaks();
|
||||
expect
|
||||
.soft(homeHeadings)
|
||||
.toEqual(
|
||||
homeHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "게임 시작 · 소리 듣기" })
|
||||
.click();
|
||||
expect.soft(homeHeadings).toEqual(
|
||||
homeHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
const question = page.getByRole("heading", {
|
||||
name: createQuestions(42, "mixed")[0]!.pattern.question,
|
||||
});
|
||||
@@ -105,18 +95,16 @@ test("한국어 제목은 단어 중간에서 줄바꿈하지 않는다", async
|
||||
// 이미지 안으로 들어오는 Chrome 캡처 아티팩트를 방지한다. 초점은 유지한다.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: `.agent/evidence/release-${info.project.name}-heading.png`,
|
||||
path: `.agent/evidence-v0.1.1/${info.project.name}-heading.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
const gameHeadings = await wordBreaks();
|
||||
expect
|
||||
.soft(gameHeadings)
|
||||
.toEqual(
|
||||
gameHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
expect.soft(gameHeadings).toEqual(
|
||||
gameHeadings.map((heading) => ({
|
||||
...heading,
|
||||
wordBreak: "keep-all",
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
await question.evaluate(
|
||||
(heading) => heading.scrollWidth <= heading.clientWidth,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// @vitest-environment node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// Python 경계 테스트도 npm test에 포함한다. provider 호출은 fake이며 ffmpeg
|
||||
// 변환과 Linux 원자 디렉터리 교체는 실제로 실행한다. 외부 네트워크는 쓰지 않는다.
|
||||
it("neural 생성기의 실패 보존·retry·PCM·비밀값 부재 계약", () => {
|
||||
const result = spawnSync(
|
||||
"python3",
|
||||
["-m", "unittest", "discover", "-s", "tests/python", "-v"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
}, 60_000);
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// 배포 표기와 재현 명령을 함께 고정한다. 과거 인수 기록은 검사·수정하지 않는다.
|
||||
it("0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
||||
expect.soft(pkg.version).toBe("0.1.1");
|
||||
expect.soft(lock.version).toBe("0.1.1");
|
||||
expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||||
for (const path of [
|
||||
"src/App.tsx",
|
||||
"index.html",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"docs/AUDIO_PROVENANCE.md",
|
||||
]) {
|
||||
expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||||
}
|
||||
});
|
||||
|
||||
it("고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
expect
|
||||
.soft(pkg.scripts["audio:generate"])
|
||||
.toBe(".venv-audio/bin/python scripts/generate_audio.py");
|
||||
for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
|
||||
const doc = readFileSync(path, "utf8");
|
||||
expect.soft(doc, path).toContain("requirements-audio.txt");
|
||||
expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||||
expect.soft(doc, path).toContain("7.3.1");
|
||||
expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||||
}
|
||||
const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
|
||||
expect
|
||||
.soft(provenance)
|
||||
.toContain("https://www.microsoft.com/en-us/servicesagreement");
|
||||
expect.soft(provenance).toContain("LGPL");
|
||||
expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
|
||||
expect
|
||||
.soft(readFileSync("LICENSE", "utf8"))
|
||||
.toContain("does not claim exclusive rights to generated speech");
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
"""외부 provider 대신 작은 PCM fixture로 실패·복구·원자 공개 계약을 검증한다.
|
||||
|
||||
실제 네트워크 생성은 별도 인수 단계에서 101개 전부 실행한다. 이 테스트의
|
||||
가짜 MP3는 adapter 경계용이며 public 자산이나 생성 evidence로 쓰지 않는다.
|
||||
"""
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import neural_audio as audio
|
||||
|
||||
ENTRIES = [{"file": "intro.wav", "text": "안내 문장"}] + [
|
||||
{"file": f"target-{i}.wav", "text": f"목표 문장 {i}"} for i in range(100)
|
||||
]
|
||||
VERSIONS = {"edge-tts": "7.3.1", "ffmpeg": "ffmpeg version fixture"}
|
||||
|
||||
|
||||
def wav(path, *, rate=24000, channels=1, width=2, duration=1, amplitude=4000):
|
||||
values = [int(amplitude * math.sin(2 * math.pi * 220 * i / rate)) for i in range(int(rate * duration))]
|
||||
with wave.open(str(path), "wb") as stream:
|
||||
stream.setparams((channels, width, rate, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(b"".join(struct.pack("<h", x) for x in values))
|
||||
|
||||
|
||||
def snapshot(path):
|
||||
return {str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() for p in path.rglob("*") if p.is_file()}
|
||||
|
||||
|
||||
class GeneratorContract(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.output = self.root / "audio"
|
||||
self.output.mkdir()
|
||||
(self.output / "intro.wav").write_bytes(b"old voice")
|
||||
(self.output / "manifest.json").write_text('{"old":true}')
|
||||
self.before = snapshot(self.output)
|
||||
self.calls = []
|
||||
self.delays = []
|
||||
self.fixture = self.root / "fixture.wav"
|
||||
wav(self.fixture)
|
||||
|
||||
def synthesize(self, text, path):
|
||||
self.assertEqual(snapshot(self.output), self.before, "생성 중 기존 공개 자산은 변하면 안 된다")
|
||||
self.assertFalse(path.exists(), "재시도는 이전 불완전 MP3를 제거해야 한다")
|
||||
self.calls.append(text)
|
||||
path.write_bytes(b"fake mp3 for adapter boundary")
|
||||
|
||||
def convert(self, source, destination):
|
||||
self.assertTrue(source.exists())
|
||||
destination.write_bytes(self.fixture.read_bytes())
|
||||
|
||||
def run_generate(self, synthesize=None, convert=None):
|
||||
with patch.object(audio, "tool_versions", return_value=VERSIONS, create=True):
|
||||
return audio.generate(ENTRIES, self.output, synthesize=synthesize or self.synthesize,
|
||||
convert=convert or self.convert, sleep=self.delays.append)
|
||||
|
||||
def assert_preserved(self):
|
||||
self.assertEqual(snapshot(self.output), self.before)
|
||||
self.assertEqual(sorted(p.name for p in self.root.iterdir()), ["audio", "fixture.wav"])
|
||||
|
||||
def test_all_101_are_validated_before_single_atomic_publication(self):
|
||||
with patch.object(audio, "atomic_publish", wraps=audio.atomic_publish) as publish:
|
||||
result = self.run_generate()
|
||||
self.assertEqual(publish.call_count, 1)
|
||||
self.assertEqual(len(self.calls), 101)
|
||||
self.assertEqual(len(result["files"]), 101)
|
||||
self.assertEqual(result, json.loads((self.output / "manifest.json").read_text()))
|
||||
self.assertEqual(result["voice"], "ko-KR-HyunsuMultilingualNeural")
|
||||
self.assertEqual(result["toolVersions"], VERSIONS)
|
||||
self.assertEqual(sorted(p.name for p in self.output.iterdir()), sorted([e["file"] for e in ENTRIES]+["manifest.json"]))
|
||||
self.assertEqual(sorted(p.name for p in self.root.iterdir()), ["audio", "fixture.wav"])
|
||||
|
||||
def test_transient_provider_failures_retry_cleanly_with_bounded_backoff(self):
|
||||
attempts = []
|
||||
def flaky(text, path):
|
||||
self.assertFalse(path.exists())
|
||||
attempts.append(text)
|
||||
if len(attempts) < 3:
|
||||
path.write_bytes(b"partial")
|
||||
raise subprocess.CalledProcessError(1, ["provider"])
|
||||
self.synthesize(text, path)
|
||||
self.run_generate(synthesize=flaky)
|
||||
self.assertEqual(len(attempts), 103)
|
||||
self.assertEqual(self.delays, [1, 2])
|
||||
|
||||
def test_exhausted_provider_failure_preserves_existing_files_and_redacts_secrets(self):
|
||||
attempts = []
|
||||
def broken(text, path):
|
||||
attempts.append(text)
|
||||
path.write_bytes(b"partial")
|
||||
if len(attempts) <= 2:
|
||||
return
|
||||
raise subprocess.CalledProcessError(1, ["provider", "credential-sentinel"], stderr="cookie=credential-sentinel")
|
||||
captured = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured):
|
||||
with self.assertRaisesRegex(RuntimeError, "target-1.wav.*3") as failure:
|
||||
self.run_generate(synthesize=broken)
|
||||
self.assertNotIn("credential-sentinel", str(failure.exception) + captured.getvalue())
|
||||
self.assertEqual(len(attempts), 5)
|
||||
self.assertEqual(self.delays, [1, 2])
|
||||
self.assert_preserved()
|
||||
|
||||
def test_timeout_is_bounded_and_preserves_existing_files(self):
|
||||
attempts = []
|
||||
def timeout(text, path):
|
||||
attempts.append(text)
|
||||
raise subprocess.TimeoutExpired("provider", 60)
|
||||
with self.assertRaisesRegex(RuntimeError, "intro.wav.*3"):
|
||||
self.run_generate(synthesize=timeout)
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assert_preserved()
|
||||
|
||||
def test_invalid_pcm_never_reaches_publication(self):
|
||||
def invalid(source, destination):
|
||||
wav(destination, amplitude=0)
|
||||
with self.assertRaisesRegex(ValueError, "무음|RMS"):
|
||||
self.run_generate(convert=invalid)
|
||||
self.assert_preserved()
|
||||
|
||||
def test_ffmpeg_failure_is_not_retried_and_preserves_existing_files(self):
|
||||
def broken(source, destination):
|
||||
raise subprocess.CalledProcessError(1, ["ffmpeg"])
|
||||
with self.assertRaisesRegex(RuntimeError, "intro.wav.*변환"):
|
||||
self.run_generate(convert=broken)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
self.assertEqual(self.delays, [])
|
||||
self.assert_preserved()
|
||||
|
||||
def test_interruption_preserves_existing_files_and_cleans_temporary_data(self):
|
||||
def interrupt(text, path):
|
||||
path.write_bytes(b"partial")
|
||||
raise KeyboardInterrupt()
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.run_generate(synthesize=interrupt)
|
||||
self.assert_preserved()
|
||||
|
||||
def test_publication_failure_preserves_existing_files(self):
|
||||
with patch.object(audio, "atomic_publish", side_effect=OSError("exchange unavailable")):
|
||||
with self.assertRaisesRegex(OSError, "exchange unavailable"):
|
||||
self.run_generate()
|
||||
self.assert_preserved()
|
||||
|
||||
def test_full_validation_detects_changed_earlier_file_before_publication(self):
|
||||
def tamper(source, destination):
|
||||
self.convert(source, destination)
|
||||
if destination.name == "target-99.wav":
|
||||
(destination.parent / "intro.wav").write_bytes(b"damaged")
|
||||
with self.assertRaises((ValueError, EOFError, wave.Error)):
|
||||
self.run_generate(convert=tamper)
|
||||
self.assert_preserved()
|
||||
|
||||
def test_wrong_edge_version_fails_before_network_or_asset_changes(self):
|
||||
with patch("importlib.metadata.version", return_value="7.2.7"):
|
||||
with self.assertRaisesRegex(RuntimeError, "7.3.1"):
|
||||
audio.generate(ENTRIES, self.output, synthesize=self.synthesize)
|
||||
self.assertEqual(self.calls, [])
|
||||
self.assert_preserved()
|
||||
|
||||
def test_rejects_incomplete_or_duplicate_or_unsafe_inventory_before_requests(self):
|
||||
for entries in [ENTRIES[:-1], [ENTRIES[0]] * 101,
|
||||
[{"file": "../escape.wav", "text": "외부"}] + ENTRIES[1:]]:
|
||||
with self.subTest(entries=entries[0]):
|
||||
with self.assertRaises(ValueError):
|
||||
audio.generate(entries, self.output, synthesize=self.synthesize)
|
||||
self.assertEqual(self.calls, [])
|
||||
self.assert_preserved()
|
||||
|
||||
def test_cli_import_has_no_generation_or_filesystem_side_effects(self):
|
||||
with patch("subprocess.check_output", side_effect=AssertionError("import 실행 금지")), \
|
||||
patch("subprocess.run", side_effect=AssertionError("import 실행 금지")):
|
||||
spec = importlib.util.spec_from_file_location("generate_audio_import_test", ROOT / "scripts/generate_audio.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
def test_edge_adapter_uses_pinned_python_prosody_timeout_and_no_shell(self):
|
||||
path = self.root / "sample.mp3"
|
||||
with patch("subprocess.run") as run:
|
||||
audio.synthesize_edge("한국어 ; $(literal)", path)
|
||||
args, kwargs = run.call_args
|
||||
self.assertEqual(args[0], [sys.executable, "-m", "edge_tts", "--voice", "ko-KR-HyunsuMultilingualNeural",
|
||||
"--rate=-6%", "--pitch=-4Hz", "--text", "한국어 ; $(literal)", "--write-media", str(path)])
|
||||
self.assertLessEqual(kwargs["timeout"], 60)
|
||||
self.assertTrue(kwargs["check"])
|
||||
self.assertFalse(kwargs.get("shell", False))
|
||||
self.assertEqual(kwargs["stderr"], subprocess.DEVNULL)
|
||||
|
||||
def test_ffmpeg_adapter_actually_decodes_mp3_to_normalized_pcm24(self):
|
||||
mp3 = self.root / "test.mp3"
|
||||
subprocess.run(["ffmpeg", "-v", "error", "-i", str(self.fixture), str(mp3)], check=True)
|
||||
output = self.root / "normalized.wav"
|
||||
audio.convert_mp3(mp3, output)
|
||||
with wave.open(str(output)) as stream:
|
||||
self.assertEqual((stream.getframerate(), stream.getnchannels(), stream.getsampwidth()), (24000, 1, 2))
|
||||
samples = struct.unpack("<" + "h" * stream.getnframes(), stream.readframes(stream.getnframes()))
|
||||
rms = math.sqrt(sum(x*x for x in samples)/len(samples))/32768
|
||||
self.assertAlmostEqual(rms, 0.1, places=3)
|
||||
self.assertLess(max(abs(x) for x in samples), 32767)
|
||||
|
||||
def test_atomic_exchange_keeps_old_tree_at_staging_path(self):
|
||||
staged = self.root / "staged"
|
||||
staged.mkdir()
|
||||
(staged / "new.wav").write_bytes(b"complete")
|
||||
audio.atomic_publish(staged, self.output)
|
||||
self.assertEqual(snapshot(staged), self.before)
|
||||
self.assertEqual((self.output / "new.wav").read_bytes(), b"complete")
|
||||
|
||||
def test_atomic_first_publication(self):
|
||||
staged = self.root / "staged"
|
||||
staged.mkdir()
|
||||
(staged / "new.wav").write_bytes(b"complete")
|
||||
output = self.root / "first"
|
||||
audio.atomic_publish(staged, output)
|
||||
self.assertFalse(staged.exists())
|
||||
self.assertEqual((output / "new.wav").read_bytes(), b"complete")
|
||||
|
||||
|
||||
class PCMContract(unittest.TestCase):
|
||||
def test_rejects_silence_clipping_bad_format_and_abnormal_duration(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "invalid.wav"
|
||||
for options in [{"amplitude": 0}, {"amplitude": 32767}, {"rate": 16000},
|
||||
{"channels": 2}, {"width": 1}, {"duration": 0.2}, {"duration": 21}]:
|
||||
with self.subTest(options=options):
|
||||
wav(path, **options)
|
||||
with self.assertRaises(ValueError):
|
||||
audio.inspect_wav(path)
|
||||
|
||||
def test_valid_pcm_returns_measured_hash_bytes_duration_and_levels(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "valid.wav"
|
||||
wav(path)
|
||||
result = audio.inspect_wav(path)
|
||||
self.assertEqual(result["duration"], 1)
|
||||
self.assertEqual(result["bytes"], path.stat().st_size)
|
||||
self.assertEqual(result["sha256"], hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
self.assertGreater(result["rms"], 0)
|
||||
self.assertLess(result["peak"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user