현재 작업 상태 저장

This commit is contained in:
Yun Chan 2026-06-27 11:20:24 +09:00
parent 07cc67761e
commit 6bd91b0d5e
674 changed files with 8726 additions and 298 deletions

View file

@ -0,0 +1,56 @@
# BIMEC 페르소나 음성 PoC - 2026-06-27
## 목적
BIMEC/Vignette 상담 시뮬레이션에서 내담자 페르소나가 텍스트만이 아니라 목소리의 정서 변화까지 표현하도록 한다. 1차 목표는 P1 서연 음성을 우울·무기력 상태에서 경계/불안, 라포 형성, 조심스러운 회복감까지 한 목소리 계열로 샘플링하는 것이다.
## 로컬 모델 조사 결과
- 채택 후보: 로컬 ComfyUI `Higgs Audio v3 TTS` 서버.
- 위치: `C:\Users\encep\Tools\ComfyUI_windows_portable\ComfyUI\custom_nodes\Higgs_v3-TTS-ComfyUI`.
- 사용: 서버 래퍼(`higgs_server.py`)는 기존 reference voice를 내장하므로 BIMEC 페르소나 제작에는 쓰지 않는다. 스크립트가 Higgs 런타임을 직접 로드해 무참조 synthetic seed voice를 먼저 만든다.
- 장점: 한국어 zero-shot voice cloning, inline emotion/style/prosody/SFX 태그, 속도·피치·pause 제어.
- 제약: 약 9~11GB VRAM 단독 여유. SUPIR/Qwen 등 GPU 작업과 동시 실행 금지.
- 라이선스/권리: Higgs Audio v3는 연구·비상업 라이선스 계열로 확인됨. 상업 서비스 탑재 전 라이선스 확정이 필요하다.
## 금지 원칙
- 실존 인물·성우·강의 음성 reference로 BIMEC 서비스 페르소나를 만들지 않는다.
- 기존 로컬 `clean_ref_seg1.wav`, `kangsujin_*`, GPT-SoVITS ref 등은 이 작업에서 금지한다.
- "그 급의 표현력"은 목표로 삼되, 음색은 무참조 synthetic seed 또는 명시 동의·라이선스가 확보된 음성만 사용한다.
## P1 서연 음성 방향
- 기본 음색: 청소년 여성으로 들리되 과도하게 아동화하지 않는다.
- 초기 상태: 떨림, 낮은 에너지, 말끝 흐림, 긴 pause.
- 경계 상태: 비밀 보장 걱정 때문에 말이 빨라지고 톤높이가 올라간다.
- 라포 이후: 속도가 조금 안정되고 숨이 덜 막힌다.
- 회복감: 밝아져도 과장 금지. "씩씩함"이 아니라 "조금 괜찮아짐" 수준.
## 산출물
- 생성 스크립트: `docs/voice-art/scripts/generate_p1_seoyeon_higgs_samples.py`
- 샘플 폴더: `docs/voice-art/p1-seoyeon-higgs-v3-20260627/`
- 생성 방식: Higgs 무참조 생성으로 `_synthetic_seed_no_external_reference`를 만들고, 그 synthetic seed만 재참조한다.
## 서비스 연결 현황
- 연결 방식: `VoiceService.synthesize_stream`에 개발용 P1 sample TTS provider를 추가했다.
- 활성화: `ENVIRONMENT=dev`에서만 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`로 켠다.
- 대상: P1 `soft-young-fem` preset만 샘플 provider를 사용한다. P2/P3와 일반 preset은 기존 OpenAI TTS 경로를 유지한다.
- 동작: AI가 생성한 내담자 응답 텍스트를 보고 우울·피로·불안·라포·회복 샘플 중 가장 가까운 MP3를 `/voice/ws` binary TTS chunk로 재생한다.
- 범위: 이 provider는 TTS 출력만 바꾼다. 실제 마이크 입력/STT 캐스케이드는 여전히 `OPENAI_API_KEY`가 있어야 가용하다.
- 한계: 현재 PoC는 답변 문장을 그대로 새 합성하지 않고 정서 샘플을 재생한다. 상업 라이선스·권리정책이 닫히기 전에는 프로덕션 배포 금지.
## 검증
- 단위/계약: `python -m unittest app.test_voice_service app.test_voice_ws app.test_session_turn_persistence app.test_runtime_policy` 42 tests OK.
- 실제 자산 스트림: P1 회복감 문장 기준 provider `p1-sample-poc`, 20 chunks, 78,573 bytes.
- 서비스 smoke: dev-login → P1 session → `/voice/ws` `text_turn` → reply/state/tts_end + binary 20 chunks 수신 확인.
## 서비스 연결 전 게이트
- 소유자 결정: Higgs 상업 라이선스 확보 또는 배포 가능한 대체 모델 선정.
- 음원 권리: 실제 reference voice를 쓰는 경우 P1/P2/P3 각 페르소나용 동의 받은 reference voice만 허용.
- 임상 검수: 감정 표현이 상담 훈련을 방해할 정도로 과장하지 않는지 확인.
- 기술 검수: 샘플 음성 duration, loudness, silence, style token drift, browser playback 확인.

View file

@ -0,0 +1,69 @@
{
"persona": "P1 서연(가명) · 고2 · 우울/자살사고",
"model": "Higgs Audio v3 TTS local runtime",
"reference_policy": "실존 인물 음성 금지. Higgs 무참조 생성 seed만 재참조한 synthetic voice.",
"license_gate": "Higgs Audio v3 상업 사용 가능 여부 확정 전 서비스 배포 금지.",
"synthetic_seed": {
"text": "안녕하세요. 저는 서연이에요. 오늘은 천천히, 너무 밝지 않게, 하지만 또렷하게 말해볼게요.",
"wav": "_synthetic_seed_no_external_reference.wav",
"mp3": "_synthetic_seed_no_external_reference.mp3",
"duration_sec": 8.72
},
"samples": [
{
"id": "p1_seoyeon_01_depressed_slow",
"label": "우울·무기력·느린 말",
"intent": "초기 비자발 내담자. 낮은 에너지, 늘어짐, 긴 침묵.",
"seed": 3101,
"text": "음... <|emotion:helplessness|><|prosody:speed_very_slow|>그냥 학교 가도 아무 의미 없는 것 같아요.<|prosody:long_pause|> 엄마는 제가 이상한 줄 알아요.<|prosody:pause|> 그냥 좀, 다 귀찮아요.",
"wav": "p1_seoyeon_01_depressed_slow.wav",
"mp3": "p1_seoyeon_01_depressed_slow.mp3",
"duration_sec": 11.68,
"bytes_mp3": 98133
},
{
"id": "p1_seoyeon_02_tired_flat",
"label": "피로·수면부족·평평한 톤",
"intent": "잠을 못 잔 상태. 감정 표현이 납작하고 문장이 짧음.",
"seed": 3102,
"text": "어제도 <|emotion:sadness|><|prosody:expressive_low|>잠을 거의 못 잤어요. 새벽까지 그냥 누워 있었고요.<|prosody:pause|> 아침 되니까 몸이 너무 무거워서, 아무것도 하기 싫었어요.",
"wav": "p1_seoyeon_02_tired_flat.wav",
"mp3": "p1_seoyeon_02_tired_flat.mp3",
"duration_sec": 11.32,
"bytes_mp3": 99861
},
{
"id": "p1_seoyeon_03_anxious_guarded",
"label": "불안·경계·말 빨라짐",
"intent": "비밀보장 걱정으로 경계가 올라간 상태. 속도와 음높이가 상승.",
"seed": 3103,
"text": "근데 선생님, <|emotion:fear|><|prosody:speed_fast|><|prosody:pitch_high|>이거 엄마한테 다 말하는 거 아니죠? 그런 거면 저 그냥 갈래요. 진짜로요.",
"wav": "p1_seoyeon_03_anxious_guarded.wav",
"mp3": "p1_seoyeon_03_anxious_guarded.mp3",
"duration_sec": 7.72,
"bytes_mp3": 67821
},
{
"id": "p1_seoyeon_04_rapport_relief",
"label": "라포 형성·조심스러운 완화",
"intent": "상담자가 버텨준 뒤 처음으로 조금 풀리는 목소리.",
"seed": 3104,
"text": "아까는 말하기 싫었는데...<|emotion:relief|><|prosody:speed_slow|>그래도 선생님은 그냥 듣고 계신 것 같아서요.<|prosody:pause|> 그건 조금, 괜찮았어요.",
"wav": "p1_seoyeon_04_rapport_relief.wav",
"mp3": "p1_seoyeon_04_rapport_relief.mp3",
"duration_sec": 9.24,
"bytes_mp3": 80037
},
{
"id": "p1_seoyeon_05_recovered_lively",
"label": "회복감·조금 활기찬 톤",
"intent": "라포와 작은 성공 경험 후. 과장 없는 밝아짐.",
"seed": 3105,
"text": "오늘은 그래도 <|emotion:contentment|><|prosody:speed_fast|>학교 끝나고 친구랑 잠깐 웃었어요.<|emotion:relief|>진짜 별거 아닌데, 조금 괜찮았어요.",
"wav": "p1_seoyeon_05_recovered_lively.wav",
"mp3": "p1_seoyeon_05_recovered_lively.mp3",
"duration_sec": 8.76,
"bytes_mp3": 78573
}
]
}

View file

@ -0,0 +1,236 @@
# -*- 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())