72 lines
3.2 KiB
Python
72 lines
3.2 KiB
Python
"""직접 작성한 한국어 문장을 로컬 포먼트 합성기로 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):,} 바이트")
|