박씨 양산 자동화 = NPU + ZipVoice 단일 트랙. GPU 0.
오늘(2026-05-07~08) 본진 e2e 3회 실패 분석에서 도출한 streaming 응답 필수 설계.
voice MCP.lecture_timeline (단일 SSE 세션)
→ 슬라이드 1 추론 (12s)
→ 슬라이드 2 추론 (12s)
...
→ 슬라이드 N 추론
→ 39슬라이드 끝나면 한 번에 sections+wav_paths 반환
문제:
voice MCP.lecture_timeline (streaming 응답)
→ progress event 1: {idx: 0, status: "synth", text: "..."}
→ progress event 1: {idx: 0, status: "ok", wav_path: "...", duration: 4.3}
→ progress event 2: {idx: 1, status: "synth", ...}
...
→ final: {sections: [...], wav_paths: [...]}
각 progress event = mcp 측 keepalive 역할 + pipeline.py가 즉시 다음 단계(actor 녹화 등) 병렬 시작 가능.
| 항목 | 값 | |
| 기본 포트 | `7768` (sovits_worker=7766 + 2) | |
|---|---|---|
| 환경변수 | `NPU_WORKER_HOST`, `NPU_WORKER_PORT` | |
| 서버 | `ThreadingHTTPServer` (sovits_worker와 동일) | |
| 백엔드 | ZipVoice ONNX + onnxruntime-qnn (1차) / Coqui XTTS (백업) | |
| 박씨 레퍼런스 | `~/parksy-audio/voice_models/parksy_ref/parksy_ref_30s.wav` (예정) |
{
"status": "ok",
"loaded": true,
"load_seconds": 8.2,
"backend": "zipvoice-onnx",
"device": "qnn",
"ref_wav": "/home/dtsli/parksy-audio/voice_models/parksy_ref/parksy_ref_30s.wav",
"version": "v1.0"
}
요청:
{
"text": "안녕하세요. 박씨 음성 합성 테스트입니다.",
"lang": "ko",
"output_path": "/tmp/parksy_air/.../s00.wav",
"speed_factor": 1.0,
"ref_wav": null
}
응답:
{
"status": "ok",
"output": "/tmp/parksy_air/.../s00.wav",
"wav_path": "/tmp/parksy_air/.../s00.wav",
"duration_sec": 4.32,
"sample_rate": 24000,
"elapsed_sec": 1.8,
"rtf": 0.42,
"engine": "zipvoice-onnx",
"device": "qnn"
}
요청:
{
"sections": [
{"idx": 0, "text": "...", "output_path": "/tmp/.../s00.wav"},
{"idx": 1, "text": "...", "output_path": "/tmp/.../s01.wav"},
...
],
"lang": "ko",
"speed_factor": 1.0,
"ref_wav": null,
"stream": true
}
응답: Content-Type: application/x-ndjson (NDJSON streaming)
{"event":"start","total":39,"backend":"zipvoice-onnx","device":"qnn"}
{"event":"progress","idx":0,"status":"synth"}
{"event":"progress","idx":0,"status":"ok","wav_path":"/tmp/.../s00.wav","duration_sec":4.32,"elapsed_sec":1.8}
{"event":"progress","idx":1,"status":"synth"}
{"event":"progress","idx":1,"status":"ok","wav_path":"/tmp/.../s01.wav","duration_sec":3.10,"elapsed_sec":1.5}
...
{"event":"progress","idx":38,"status":"ok",...}
{"event":"done","total":39,"ok_count":39,"fail_count":0,"elapsed_total_sec":78.5}
핵심 약속:
streaming 안 되는 환경(예: Cloudflare/proxy buffering)을 위한 polling 대안.
응답:
{"job_id": "abc123", "status": "queued", "total": 39}
GET /jobs/abc123/status →
{
"job_id": "abc123",
"status": "running",
"done": 12,
"total": 39,
"ok": 12,
"fail": 0,
"current_idx": 12,
"elapsed_sec": 14.2
}
GET /jobs/abc123/result (완료 후) →
{
"job_id": "abc123",
"status": "done",
"sections": [...],
"wav_paths": [...],
"elapsed_total_sec": 78.5
}
{
"status": "fail",
"error": "ModelLoadError: ZipVoice ONNX 모델 로드 실패",
"traceback": "...",
"fallback": "cpu"
}
HTTP 상태:
# 환경변수 하나로 백엔드 갈아끼움
_WORKER_BASE = os.environ.get(
"TTS_WORKER_BASE",
f"http://{_WORKER_HOST}:{_WORKER_PORT}", # 기본 sovits 7766
)
# /synth 호출은 동일 — 응답 포맷 호환
# /synth_batch 새 헬퍼만 추가
TTS_WORKER_BASE=http://127.0.0.1:7768 설정만으로 NPU 워커로 전환.
parksy_voice/streaming_client.py (신설 예정) — lecture_timeline 내부에서 호출:
async def synth_batch_streaming(
sections: list[dict],
on_progress: Callable[[int, dict], None] | None = None,
) -> dict:
"""NDJSON streaming으로 슬라이드별 progress 받음."""
body = json.dumps({"sections": sections, "stream": True}).encode("utf-8")
req = urllib.request.Request(f"{_WORKER_BASE}/synth_batch",
data=body, headers={"Content-Type": "application/json"})
results = []
with urllib.request.urlopen(req, timeout=3600) as resp:
for raw_line in resp:
line = raw_line.decode("utf-8").strip()
if not line:
continue
event = json.loads(line)
if event["event"] == "progress" and event.get("status") == "ok":
results.append(event)
if on_progress:
on_progress(event["idx"], event)
elif event["event"] == "done":
break
elif event["event"] == "error":
# 개별 슬라이드 실패는 결과에만 기록, 전체 중단 X
if on_progress:
on_progress(event.get("idx", -1), event)
return {"sections": results, "wav_paths": [r["wav_path"] for r in results]}
핵심: urlopen(...)이 line iterator 지원 → 각 NDJSON 라인이 들어올 때마다 즉시 처리. mcp RPC default timeout 회피.
1. ✅ 인터페이스 명세 (이 문서)
2. NEXT — npu_worker.py 스켈레톤 + sovits_worker fallback (ZipVoice 통합 전이라도 동일 인터페이스로 sovits 호출)
3. streaming_client.py 작성 + 단위 테스트
4. voice MCP lecture_timeline 내부를 batch streaming 호출로 교체
5. phone_aider DeepSeek 결과 받아 ZipVoice ONNX 모델 통합
6. onnxruntime-qnn EP 추가 (qnn 실패 시 cpu fallback)
7. 본진 39슬라이드 e2e v4 → 풀 통과 검증
이 문서가 박힌 후 변경 시 v2 별도 명세서로 만든다. v1 호환성 유지.
| 항목 | 안정성 | |
| `/synth` 요청/응답 schema | 🔒 sovits_worker와 영구 호환 | |
|---|---|---|
| `/synth_batch` NDJSON event 포맷 | 🔒 v1 고정 (event/idx/status/wav_path 필드 영구) | |
| `TTS_WORKER_BASE` 환경변수 | 🔒 영구 | |
| 새 backend 추가 | ✅ device 필드만 늘리면 됨 |
| 헌법 | 충족 | |
| 양산 자동화 (박씨 시간 0) | ✅ batch streaming으로 39슬라이드 무인 처리 | |
|---|---|---|
| GPT-SoVITS 박씨 음색 | ✅ ZipVoice 검증 통과 시 / 부족 시 1회 파인튜닝 | |
| GPU 0 (양산) | ✅ NPU 직행 / Vast.ai는 1회성 파인튜닝만 | |
| 강박적 redundancy 금지 | ✅ 단일 트랙 (sovits → npu 전환). 사용 중 백엔드 1개 | |
| edge-tts 양산 금지 | ✅ NPU 워커는 박씨 음색 only, edge-tts fallback X |
작성: 2026-05-08
관련 커밋: 02f2650 (sovits_worker 베이스), bcf1e6d (pipeline timeout)
다음 작업: npu_worker.py 스켈레톤 (Task #8)