feat: improve Korean neural voice quality for v0.1.1
This commit is contained in:
+28
-64
@@ -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)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""한국어 neural 음성을 임시 공간에서 완성하고 검증 후 한 번에 공개한다.
|
||||
|
||||
외부 서비스는 생성할 때만 접근한다. public에는 PCM과 허용된 출처 정보만
|
||||
남긴다. 테스트는 provider/변환 경계에 fake를 넣어 네트워크 없이 실패를 재현한다.
|
||||
"""
|
||||
|
||||
from array import array
|
||||
import ctypes
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VOICE = "ko-KR-HyunsuMultilingualNeural"
|
||||
RATE = "-6%"
|
||||
PITCH = "-4Hz"
|
||||
SAMPLE_RATE = 24000
|
||||
EDGE_VERSION = "7.3.1"
|
||||
ATTEMPTS = 3
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
def tool_versions():
|
||||
"""실행 중인 도구를 조회한다. 다른 edge-tts를 몰래 사용하는 fallback은 없다."""
|
||||
try:
|
||||
edge = importlib.metadata.version("edge-tts")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
edge = "missing"
|
||||
if edge != EDGE_VERSION:
|
||||
raise RuntimeError("edge-tts==7.3.1이 필요합니다. requirements-audio.txt로 venv를 설치하세요.")
|
||||
result = subprocess.run(["ffmpeg", "-version"], check=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=10)
|
||||
return {"edge-tts": edge, "ffmpeg": result.stdout.splitlines()[0]}
|
||||
|
||||
|
||||
def synthesize_edge(text, path):
|
||||
"""별도 프로세스의 전체 시간을 제한하고 provider 응답·인증 헤더를 기록하지 않는다."""
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "edge_tts", "--voice", VOICE,
|
||||
f"--rate={RATE}", f"--pitch={PITCH}", "--text", text,
|
||||
"--write-media", str(path),
|
||||
], check=True, timeout=TIMEOUT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def pcm_values(data):
|
||||
samples = array("h", data)
|
||||
if sys.byteorder != "little":
|
||||
samples.byteswap()
|
||||
return samples
|
||||
|
||||
|
||||
def levels(samples):
|
||||
if not samples:
|
||||
raise ValueError("빈 PCM입니다.")
|
||||
rms = math.sqrt(sum(x*x for x in samples) / len(samples)) / 32768
|
||||
peak = max(abs(x) for x in samples) / 32768
|
||||
return rms, peak
|
||||
|
||||
|
||||
def convert_mp3(source, destination):
|
||||
"""24kHz mono로 디코드한 뒤 파일 전체에 동일 gain만 적용한다.
|
||||
|
||||
RMS 0.10을 목표로 하되 최대 증폭 2배, peak 0.89로 제한한다. 압축·게이트·
|
||||
잘라내기 없이 자음의 시간·주파수 정보를 보존하고 작은 입력도 과증폭하지 않는다.
|
||||
"""
|
||||
decoded = subprocess.run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
"-ar", str(SAMPLE_RATE), "-ac", "1", "-f", "s16le", "pipe:1",
|
||||
], check=True, timeout=TIMEOUT, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout
|
||||
samples = pcm_values(decoded)
|
||||
rms, peak = levels(samples)
|
||||
if rms <= 100 / 32768:
|
||||
raise ValueError("무음 또는 RMS가 너무 낮습니다.")
|
||||
gain = min(0.1 / rms, 0.89 / peak, 2.0)
|
||||
normalized = array("h", (round(x * gain) for x in samples))
|
||||
if sys.byteorder != "little":
|
||||
normalized.byteswap()
|
||||
# wave 모듈의 표준 44-byte PCM 헤더로 도구별 부가 메타데이터 차이를 피한다.
|
||||
with wave.open(str(destination), "wb") as stream:
|
||||
stream.setparams((1, 2, SAMPLE_RATE, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(normalized.tobytes())
|
||||
|
||||
|
||||
def inspect_wav(path):
|
||||
"""헤더뿐 아니라 전체 PCM을 측정한다. 기준 밖 파일은 공개 대상에서 제외한다."""
|
||||
data = path.read_bytes()
|
||||
with wave.open(str(path), "rb") as stream:
|
||||
if (stream.getframerate(), stream.getnchannels(), stream.getsampwidth(), stream.getcomptype()) != (SAMPLE_RATE, 1, 2, "NONE"):
|
||||
raise ValueError(f"{path.name}: 24kHz mono PCM16이 아닙니다.")
|
||||
duration = stream.getnframes() / SAMPLE_RATE
|
||||
if not 1 <= duration <= 20:
|
||||
raise ValueError(f"{path.name}: 길이 {duration:.3f}초가 1–20초 범위를 벗어났습니다.")
|
||||
raw = stream.readframes(stream.getnframes())
|
||||
if len(raw) != stream.getnframes() * 2 or len(data) != len(raw) + 44:
|
||||
raise ValueError(f"{path.name}: 잘리거나 비표준 PCM 파일입니다.")
|
||||
rms, peak = levels(pcm_values(raw))
|
||||
if rms <= 100 / 32768:
|
||||
raise ValueError(f"{path.name}: 무음 또는 RMS가 너무 낮습니다.")
|
||||
if peak >= 32767 / 32768:
|
||||
raise ValueError(f"{path.name}: full-scale peak입니다.")
|
||||
if peak / rms * 0.03 * 2 >= 0.8:
|
||||
raise ValueError(f"{path.name}: 런타임 gain의 peak 여유가 부족합니다.")
|
||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data),
|
||||
"duration": duration, "rms": rms, "peak": peak}
|
||||
|
||||
|
||||
def atomic_publish(staged, output):
|
||||
"""같은 파일시스템의 완성 디렉터리를 한 syscall로 교체한다 (Linux).
|
||||
|
||||
rename 두 번을 사용하면 public/audio가 잠깐 사라진다. RENAME_EXCHANGE는
|
||||
기존 트리를 staged로 돌려놓아 실패 시 기존 이름을 유지하고 성공 시에만
|
||||
임시 폴더 정리로 이전 파일이 제거되게 한다. 미지원 환경은 안전하게 실패한다.
|
||||
"""
|
||||
if not output.exists():
|
||||
os.rename(staged, output)
|
||||
return
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
exchange = getattr(libc, "renameat2", None)
|
||||
if exchange is None:
|
||||
raise OSError("이 환경은 원자 디렉터리 교환(renameat2)을 지원하지 않습니다.")
|
||||
exchange.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
exchange.restype = ctypes.c_int
|
||||
if exchange(-100, os.fsencode(staged), -100, os.fsencode(output), 2) != 0:
|
||||
error = ctypes.get_errno()
|
||||
raise OSError(error, "원자 디렉터리 교환 실패; 기존 음성을 보존했습니다.")
|
||||
|
||||
|
||||
def generate(entries, output, *, synthesize=None, convert=None, sleep=None):
|
||||
"""모든 입력·도구를 확인한 뒤 생성한다. 어떤 실패도 기존 public을 수정하지 않는다."""
|
||||
names = [e["file"] for e in entries]
|
||||
if (len(entries) != 101 or len(set(names)) != 101 or names[0] != "intro.wav"
|
||||
or any(not re.fullmatch(r"[a-z0-9-]+\.wav", name) for name in names)
|
||||
or any(not isinstance(e["text"], str) or not e["text"].strip() for e in entries)):
|
||||
raise ValueError("안내 1개와 고유한 안전 경로의 목표 문장 100개가 필요합니다.")
|
||||
versions = tool_versions()
|
||||
synthesize = synthesize or synthesize_edge
|
||||
convert = convert or convert_mp3
|
||||
sleep = sleep or time.sleep
|
||||
output = Path(output)
|
||||
if output.is_symlink():
|
||||
raise ValueError("음성 출력 디렉터리에 symlink를 사용할 수 없습니다.")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".audio-stage-", dir=output.parent) as temporary:
|
||||
working = Path(temporary)
|
||||
ready = working / "ready"
|
||||
ready.mkdir()
|
||||
files = []
|
||||
for index, entry in enumerate(entries, 1):
|
||||
mp3 = working / "speech.mp3"
|
||||
for attempt in range(1, ATTEMPTS + 1):
|
||||
mp3.unlink(missing_ok=True)
|
||||
try:
|
||||
synthesize(entry["text"], mp3)
|
||||
if not mp3.is_file() or mp3.stat().st_size == 0:
|
||||
raise OSError("provider가 빈 음성을 반환했습니다.")
|
||||
break
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
# 원래 예외의 URL·응답·명령 인자는 기록하지 않는다. traceback의
|
||||
# cause도 숨겨 service 내부 인증값이 로그에 섞이지 않도록 한다.
|
||||
print(f"{entry['file']}: provider 시도 {attempt}/{ATTEMPTS} 실패", flush=True)
|
||||
if attempt == ATTEMPTS:
|
||||
raise RuntimeError(f"{entry['file']}: provider 생성 {ATTEMPTS}회 실패; 기존 자산 보존") from None
|
||||
sleep(2 ** (attempt - 1))
|
||||
destination = ready / entry["file"]
|
||||
try:
|
||||
convert(mp3, destination)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
raise RuntimeError(f"{entry['file']}: PCM 변환 실패; 기존 자산 보존") from None
|
||||
finally:
|
||||
mp3.unlink(missing_ok=True)
|
||||
files.append({**entry, **inspect_wav(destination)})
|
||||
print(f"[{index:03}/101] {entry['file']} {files[-1]['duration']:.3f}s", flush=True)
|
||||
# 앞서 생성한 파일도 마지막에 다시 읽는다. manifest와 실제 집합·바이트를
|
||||
# 모두 대조해야 생성 중 손상된 파일이나 임시 파일의 유출을 막을 수 있다.
|
||||
if sorted(p.name for p in ready.iterdir()) != sorted(names):
|
||||
raise ValueError("생성된 파일 집합이 원문 목록과 다릅니다.")
|
||||
for entry in files:
|
||||
if {**entry, **inspect_wav(ready / entry["file"])} != entry:
|
||||
raise ValueError(f"{entry['file']}: 최종 검증에서 파일이 변경되었습니다.")
|
||||
if sum(e["bytes"] for e in files) >= 48 * 1024 * 1024:
|
||||
raise ValueError("음성 자산이 48MiB 예산을 초과했습니다.")
|
||||
manifest = {
|
||||
"version": json.loads((ROOT / "package.json").read_text())["version"],
|
||||
"provider": "Microsoft Edge online TTS", "voice": VOICE, "rate": RATE, "pitch": PITCH,
|
||||
"sampleRate": SAMPLE_RATE, "channels": 1, "sampleFormat": "PCM s16le",
|
||||
"toolVersions": versions, "generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "https://github.com/rany2/edge-tts",
|
||||
"method": "온라인 neural 생성 MP3 → FFmpeg decode → 파일별 선형 gain → 로컬 PCM WAV",
|
||||
"normalization": {"targetRms": 0.1, "peakCeiling": 0.89, "maxGain": 2.0},
|
||||
"files": files,
|
||||
}
|
||||
(ready / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
atomic_publish(ready, output)
|
||||
return manifest
|
||||
Reference in New Issue
Block a user