현재 작업 상태 저장
This commit is contained in:
parent
07cc67761e
commit
6bd91b0d5e
674 changed files with 8726 additions and 298 deletions
56
docs/voice-art/bimec-persona-voice-poc-2026-06-27.md
Normal file
56
docs/voice-art/bimec-persona-voice-poc-2026-06-27.md
Normal 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 확인.
|
||||
Binary file not shown.
Binary file not shown.
69
docs/voice-art/p1-seoyeon-higgs-v3-20260627/manifest.json
Normal file
69
docs/voice-art/p1-seoyeon-higgs-v3-20260627/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
236
docs/voice-art/scripts/generate_p1_seoyeon_higgs_samples.py
Normal file
236
docs/voice-art/scripts/generate_p1_seoyeon_higgs_samples.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue