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
+28 -64
View File
@@ -1,71 +1,35 @@
"""직접 작성한 한국어 문장을 로컬 포먼트 합성기로 WAV로 만든다.
"""앱 원문 101개를 pinned edge-tts로 새로 생성하는 명령 진입점.
네트워크 TTS·녹음 샘플·API 키를 사용하지 않는다. 원문은 TypeScript 데이터에서
읽어 중복 관리를 없애고, 바이트 해시를 남겨 빌드에서 자산 누락·변조를 찾는다.
import에는 부작용이 없다. 원문은 앱의 renderSentence를 그대로 호출하며
네트워크 생성·검증·원자 교체는 neural_audio가 담당한다. 빌드는 생성기를 호출하지 않는다.
"""
import hashlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import wave
import sys
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")
from neural_audio import ROOT, generate
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):,} 바이트")
def main():
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, timeout=10)
manifest = generate(json.loads(source), ROOT / "public/audio")
print(f"{len(manifest['files'])}개 신규 WAV 원자 교체 완료, "
f"합계 {sum(e['bytes'] for e in manifest['files']):,} 바이트")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("음성 생성 중단; 완성 전 공개 자산 변경 없음", file=sys.stderr)
sys.exit(130)
except Exception as error:
# 인증 응답이 섞일 수 있는 외부 예외는 adapter에서 안전한 메시지로 바뀐다.
print(f"음성 생성 실패: {error}", file=sys.stderr)
sys.exit(1)