# -*- coding: utf-8 -*- """P1 서연 Higgs v3 무참조 synthetic voice 샘플 생성기. 실존 인물 reference voice를 쓰지 않는다. 먼저 Higgs 무참조 생성으로 synthetic seed voice를 만들고, 그 seed audio만 재참조해 같은 페르소나 화색의 정서/속도 변주를 만든다. 권장 실행: cd D:/workspace/vignette C:/Users/encep/Tools/ComfyUI_windows_portable/python_embeded/python.exe \ docs/voice-art/scripts/generate_p1_seoyeon_higgs_samples.py """ from __future__ import annotations import importlib import json import subprocess import sys import wave from pathlib import Path import soundfile as sf import torch BASE = Path(r"C:\Users\encep\Tools\ComfyUI_windows_portable") NODE = BASE / "ComfyUI" / "custom_nodes" FFMPEG = Path( r"C:\Users\encep\AppData\Local\Microsoft\WinGet\Packages" r"\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe" r"\ffmpeg-8.1-full_build\bin\ffmpeg.exe" ) OUT_DIR = Path(__file__).resolve().parents[1] / "p1-seoyeon-higgs-v3-20260627" SYNTHETIC_SEED_TEXT = ( "안녕하세요. 저는 서연이에요. 오늘은 천천히, 너무 밝지 않게, " "하지만 또렷하게 말해볼게요." ) SAMPLES = [ { "id": "p1_seoyeon_01_depressed_slow", "label": "우울·무기력·느린 말", "intent": "초기 비자발 내담자. 낮은 에너지, 늘어짐, 긴 침묵.", "seed": 3101, "text": ( "음... <|emotion:helplessness|><|prosody:speed_very_slow|>" "그냥 학교 가도 아무 의미 없는 것 같아요." "<|prosody:long_pause|> 엄마는 제가 이상한 줄 알아요." "<|prosody:pause|> 그냥 좀, 다 귀찮아요." ), }, { "id": "p1_seoyeon_02_tired_flat", "label": "피로·수면부족·평평한 톤", "intent": "잠을 못 잔 상태. 감정 표현이 납작하고 문장이 짧음.", "seed": 3102, "text": ( "어제도 <|emotion:sadness|><|prosody:expressive_low|>" "잠을 거의 못 잤어요. 새벽까지 그냥 누워 있었고요." "<|prosody:pause|> 아침 되니까 몸이 너무 무거워서," " 아무것도 하기 싫었어요." ), }, { "id": "p1_seoyeon_03_anxious_guarded", "label": "불안·경계·말 빨라짐", "intent": "비밀보장 걱정으로 경계가 올라간 상태. 속도와 음높이가 상승.", "seed": 3103, "text": ( "근데 선생님, <|emotion:fear|><|prosody:speed_fast|>" "<|prosody:pitch_high|>이거 엄마한테 다 말하는 거 아니죠?" " 그런 거면 저 그냥 갈래요. 진짜로요." ), }, { "id": "p1_seoyeon_04_rapport_relief", "label": "라포 형성·조심스러운 완화", "intent": "상담자가 버텨준 뒤 처음으로 조금 풀리는 목소리.", "seed": 3104, "text": ( "아까는 말하기 싫었는데..." "<|emotion:relief|><|prosody:speed_slow|>" "그래도 선생님은 그냥 듣고 계신 것 같아서요." "<|prosody:pause|> 그건 조금, 괜찮았어요." ), }, { "id": "p1_seoyeon_05_recovered_lively", "label": "회복감·조금 활기찬 톤", "intent": "라포와 작은 성공 경험 후. 과장 없는 밝아짐.", "seed": 3105, "text": ( "오늘은 그래도 <|emotion:contentment|><|prosody:speed_fast|>" "학교 끝나고 친구랑 잠깐 웃었어요." "<|emotion:relief|>진짜 별거 아닌데," " 조금 괜찮았어요." ), }, ] def import_higgs_modules(): if not NODE.exists(): raise RuntimeError(f"Higgs custom node 경로가 없다: {NODE}") sys.path.insert(0, str(NODE)) loader = importlib.import_module("Higgs_v3-TTS-ComfyUI.loader") native = importlib.import_module("Higgs_v3-TTS-ComfyUI.native") return loader, native def load_bundle(loader): choices = loader.get_model_choices() if not choices: raise RuntimeError("Higgs 모델 선택지가 없다.") choice = choices[0] print(f"[load] {choice}", flush=True) return loader.load_higgs_bundle( model_choice=choice, dtype_name="auto", device_name="auto", attention="auto", download_if_missing=False, ) def synth(native, bundle, *, text: str, seed: int, reference_audio=None, reference_text: str = "") -> dict: return native.generate_higgs_audio( bundle, text=text, reference_audio=reference_audio, reference_audio_path="", reference_text=reference_text, max_new_tokens=2048, temperature=0.8, top_p=0.95, top_k=50, seed=seed, trim_reference_audio=True, silence_threshold_db=-42.0, max_reference_seconds=12.0, progress_callback=None, ) def write_wav(audio: dict, path: Path) -> None: waveform = audio["waveform"] if not isinstance(waveform, torch.Tensor): waveform = torch.as_tensor(waveform) data = waveform.detach().float().cpu() if data.ndim == 3: data = data[0] if data.ndim == 2: data = data.numpy().T else: data = data.numpy() sf.write(str(path), data, int(audio["sample_rate"])) def wav_duration(path: Path) -> float: with wave.open(str(path), "rb") as wf: return wf.getnframes() / float(wf.getframerate()) def convert_mp3(wav_path: Path, mp3_path: Path) -> None: if not FFMPEG.exists(): raise RuntimeError(f"ffmpeg를 찾을 수 없다: {FFMPEG}") subprocess.run( [str(FFMPEG), "-y", "-i", str(wav_path), "-codec:a", "libmp3lame", "-q:a", "2", str(mp3_path)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def main() -> int: loader, native = import_higgs_modules() bundle = load_bundle(loader) OUT_DIR.mkdir(parents=True, exist_ok=True) print("[synth] synthetic_seed_no_external_reference", flush=True) seed_audio = synth(native, bundle, text=SYNTHETIC_SEED_TEXT, seed=2701) seed_wav = OUT_DIR / "_synthetic_seed_no_external_reference.wav" seed_mp3 = OUT_DIR / "_synthetic_seed_no_external_reference.mp3" write_wav(seed_audio, seed_wav) convert_mp3(seed_wav, seed_mp3) manifest = { "persona": "P1 서연(가명) · 고2 · 우울/자살사고", "model": "Higgs Audio v3 TTS local runtime", "reference_policy": "실존 인물 음성 금지. Higgs 무참조 생성 seed만 재참조한 synthetic voice.", "license_gate": "Higgs Audio v3 상업 사용 가능 여부 확정 전 서비스 배포 금지.", "synthetic_seed": { "text": SYNTHETIC_SEED_TEXT, "wav": seed_wav.name, "mp3": seed_mp3.name, "duration_sec": round(wav_duration(seed_wav), 2), }, "samples": [], } for sample in SAMPLES: wav_path = OUT_DIR / f"{sample['id']}.wav" mp3_path = OUT_DIR / f"{sample['id']}.mp3" print(f"[synth] {sample['id']} - {sample['label']}", flush=True) audio = synth( native, bundle, text=sample["text"], seed=sample["seed"], reference_audio=seed_audio, reference_text=SYNTHETIC_SEED_TEXT, ) write_wav(audio, wav_path) convert_mp3(wav_path, mp3_path) manifest["samples"].append( { **sample, "wav": wav_path.name, "mp3": mp3_path.name, "duration_sec": round(wav_duration(wav_path), 2), "bytes_mp3": mp3_path.stat().st_size, } ) (OUT_DIR / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(f"[done] {OUT_DIR}") return 0 if __name__ == "__main__": raise SystemExit(main())