feat: release Find-Word v0.1

This commit is contained in:
oz
2026-10-14 06:19:24 +00:00
parent 28ee2ce498
commit 1c0069c367
258 changed files with 21509 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포
// 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다.
export function verifyBuild(directory) {
// 번들에 실제 포함되는 전이 의존성도 잠금 파일에서 찾는다. 프로젝트의
// 비공개 라이선스와 별개로 설치된 MIT 전문·버전을 사본 세 곳에서 보존한다.
try {
const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8");
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
for (const [path, pkg] of Object.entries(lock.packages)) {
if (!path || pkg.dev) continue;
const name = path.split("node_modules/").at(-1);
const license = readFileSync(`${path}/LICENSE`, "utf8").trim();
if (!notice.includes(`## ${name} ${pkg.version}\n\n${license}`))
throw new Error(`${name} ${pkg.version}의 전문이 없습니다.`);
}
for (const path of [
"public/THIRD_PARTY_NOTICES.md",
resolve(directory, "THIRD_PARTY_NOTICES.md"),
]) {
if (readFileSync(path, "utf8") !== notice)
throw new Error(`${path}가 원본과 다릅니다.`);
}
} catch (cause) {
throw new Error("런타임 의존성 notice 누락 또는 불일치", { cause });
}
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
);
const builtManifest = JSON.parse(
readFileSync(resolve(directory, "audio/manifest.json"), "utf8"),
);
if (JSON.stringify(manifest) !== JSON.stringify(builtManifest))
throw new Error("빌드 음성 목록이 원본과 다릅니다.");
for (const entry of manifest.files) {
const bytes = readFileSync(resolve(directory, "audio", entry.file));
if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256)
throw new Error(`빌드 자산 해시 불일치: ${entry.file}`);
}
return manifest.files.length;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log(
`빌드 음성 자산 ${verifyBuild("dist")}개 및 런타임 notice 전문 확인`,
);
}
+71
View File
@@ -0,0 +1,71 @@
"""직접 작성한 한국어 문장을 로컬 포먼트 합성기로 WAV로 만든다.
네트워크 TTS·녹음 샘플·API 키를 사용하지 않는다. 원문은 TypeScript 데이터에서
읽어 중복 관리를 없애고, 바이트 해시를 남겨 빌드에서 자산 누락·변조를 찾는다.
"""
import hashlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import wave
ROOT = Path(__file__).resolve().parent.parent
OUTPUT = ROOT / "public/audio"
LOCAL = ROOT / ".cache/espeak/root/usr"
ENV = os.environ.copy()
ENGINE = shutil.which("espeak-ng")
if ENGINE is None:
# 시스템 설치 권한 없이 worktree 안에 압축 해제한 Ubuntu 패키지도 지원한다.
ENGINE = str(LOCAL / "bin/espeak-ng")
ENV["LD_LIBRARY_PATH"] = str(LOCAL / "lib/x86_64-linux-gnu")
ENV["ESPEAK_DATA_PATH"] = str(LOCAL / "lib/x86_64-linux-gnu/espeak-ng-data")
source = subprocess.check_output([
"node", "--experimental-strip-types", "--input-type=module", "-e",
"import {INTRO,PATTERNS,WORDS,renderSentence} from './src/content.ts'; "
"console.log(JSON.stringify([{file:'intro.wav',text:INTRO},"
"...PATTERNS.flatMap(p=>WORDS.map(w=>({file:p.id+'-'+w.id+'.wav',"
"text:renderSentence(p,w)})))]));",
], cwd=ROOT, text=True)
entries = json.loads(source)
OUTPUT.mkdir(parents=True, exist_ok=True)
# 버전을 상수로 쓰면 다른 PC에서 재생성할 때 출처가 거짓이 된다. 실행 중인
# 도구에서 직접 읽되 eSpeak의 로컬 데이터 경로는 재현성 비교에서 제외한다.
versions = {
"espeak": subprocess.check_output([ENGINE, "--version"], env=ENV, text=True).split("Data at:")[0].strip(),
"ffmpeg": subprocess.check_output(["ffmpeg", "-version"], text=True).splitlines()[0],
}
for entry in entries:
# 명령 인자로 텍스트를 넘겨 쉘 보간을 피한다. 합성 속도·피치·리샘플링
# 설정을 고정하고 메타데이터 없는 표준 PCM 헤더를 직접 작성한다.
speech = subprocess.check_output([
ENGINE, "-v", "ko", "-s", "145", "-p", "45", "-a", "100", "--stdout", entry["text"],
], env=ENV)
pcm = subprocess.check_output([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0",
"-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1",
], input=speech)
stream = io.BytesIO()
with wave.open(stream, "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(16000)
output.writeframes(pcm)
data = stream.getvalue()
(OUTPUT / entry["file"]).write_bytes(data)
entry["sha256"] = hashlib.sha256(data).hexdigest()
entry["bytes"] = len(data)
manifest = {
"generator": "eSpeak NG ko, speed 145, pitch 45, amplitude 100; FFmpeg PCM 16kHz mono s16",
"toolVersions": versions,
"source": "https://github.com/espeak-ng/espeak-ng",
"method": "자체 작성 문장의 오프라인 포먼트 합성; 외부 녹음·네트워크 TTS 없음",
"files": entries,
}
(OUTPUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
print(f"{len(entries)}개 WAV 생성, 합계 {sum(e['bytes'] for e in entries):,} 바이트")