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
+1 -1
View File
@@ -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
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);
});
});
+11 -26
View File
@@ -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
View File
@@ -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,
+14
View File
@@ -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);
+44
View File
@@ -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");
});
+256
View File
@@ -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()