Skip to content

Commit f458162

Browse files
committed
feat(backend,ai,realtime): 음성 스트림 WS 관련 작업
1 parent 321f090 commit f458162

29 files changed

Lines changed: 861 additions & 18 deletions

File tree

ai/CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
| 스키마 | Pydantic 2.x + pydantic-settings |
1818
| HTTP | httpx |
1919
| MQ | aio-pika (async AMQP) |
20+
| WebSocket | FastAPI WS (서버) + `websockets` (Deepgram Live 클라이언트) |
2021
| 객체 스토리지 | boto3 (S3 호환) |
2122
| LLM | LangChain 1.x (core + community) |
2223
| 로깅 | structlog |
@@ -190,6 +191,8 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
190191
- 셀프호스팅 옵션: `whisper.cpp` 또는 `faster-whisper` (GPU 권장, 비용 ↓ but 운영 부담 ↑)
191192
- 브라우저 내장 SpeechRecognition API는 정확도 부족으로 채택 안 함
192193
- **TTS: OpenAI TTS 채택** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. `TtsProvider` 추상화 + `OpenAiTtsProvider`(`gpt-4o-mini-tts`, mp3)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 OPENAI_API_KEY 보유 시 openai). `generate.tts` consumer 가 합성 → S3 PUT → `callback.tts` 발행.
194+
- **스트리밍 STT (실시간 음성 답변, RT3): Deepgram Live** (`voice/stt/deepgram_live.py`) — `websockets`로 Deepgram WS(`wss://api.deepgram.com/v1/listen`, nova-2)에 연결, interim/final 자막을 실시간 반환. `voice/stt/live.py`(`LiveSttProvider`/`LiveSttSession` 추상) + `voice/stt/mock_live.py`(키 없을 때 fallback) + `voice/stt/live_factory.py`(`LIVE_STT_PROVIDER=auto`면 DEEPGRAM_API_KEY 보유 시 deepgram_live).
195+
- FastAPI WS 엔드포인트 `/internal/voice/stream`(`api/voice_stream.py`): RealTime이 프록시한 오디오를 받아 부분/최종 자막을 다운 프레임(`transcript.partial`/`transcript.final`)으로 보내고, 발화 종료(`stop` 또는 UtteranceEnd) 시 메트릭 계산 후 `callback.voice` 발행 → 기존 followup 파이프라인 재사용.
193196
- 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py`
194197
- 분석:
195198
- WPM = words / minutes
@@ -327,6 +330,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai
327330
LangChain `AsyncCallbackHandler` 가 토큰/latency 측정 → Core `/api/internal/ai-logs` POST.
328331
- **질문 TTS consumer 본 구현** (`messaging/consumers/tts_consumer.py`, `voice/tts/`):
329332
`generate.tts` 수신 → OpenAI TTS 합성(`OpenAiTtsProvider`, mock fallback) → S3 PUT(`interview/tts/{sessionId}/{messageId}.mp3`) → `callback.tts` 발행.
330-
- 음성 분석(STT/WPM/filler) 모듈은 Phase 2
333+
- **실시간 스트리밍 음성 답변 본 구현** (RT3, `api/voice_stream.py`, `voice/stt/{live,mock_live,deepgram_live,live_factory}.py`):
334+
FastAPI WS `/internal/voice/stream` 수신(RealTime 프록시 경유) → Deepgram Live(`deepgram_live.py`, mock fallback)로 부분/최종 자막 다운 → 발화 종료 시 메트릭 계산 후 `callback.voice` 발행. `VoiceCallbackService`/followup 무변경 재사용. 신규 의존성 `websockets`.
335+
- 배치 음성 분석(STT/WPM/filler) 모듈은 `voice/stt/whisper_api.py`(+ Deepgram) + `voice/analysis/metrics.py`로 본 구현(`analyze.voice` consumer)
331336

332337
각 도입 시 본 문서 갱신.

ai/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies = [
2424
"langchain-community>=0.4.1",
2525
"langchain-openai>=0.3.0",
2626
"structlog>=25.5.0",
27+
"websockets>=13",
2728
]
2829

2930
[project.optional-dependencies]
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
5+
import structlog
6+
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
7+
8+
from ai_server.config.settings import Settings
9+
from ai_server.messaging.publisher import CallbackPublisher
10+
from ai_server.model.envelope import MessageContext
11+
from ai_server.model.messages.voice import VoiceCallbackPayload
12+
from ai_server.voice.analysis.metrics import analyze
13+
from ai_server.voice.stt.live import LiveSttProvider
14+
15+
log = structlog.get_logger(__name__)
16+
17+
router = APIRouter()
18+
19+
20+
def _provider(ws: WebSocket) -> LiveSttProvider:
21+
return ws.app.state.live_stt_provider
22+
23+
24+
def _publisher(ws: WebSocket) -> CallbackPublisher:
25+
return ws.app.state.callback_publisher
26+
27+
28+
def _settings(ws: WebSocket) -> Settings:
29+
return ws.app.state.settings
30+
31+
32+
@router.websocket("/internal/voice/stream")
33+
async def voice_stream(
34+
ws: WebSocket,
35+
session_id: int = Query(..., alias="sessionId"),
36+
message_id: int = Query(..., alias="messageId"),
37+
content_type: str = Query("audio/webm", alias="contentType"),
38+
api_key: str | None = Query(None, alias="apiKey"),
39+
) -> None:
40+
settings = _settings(ws)
41+
# 내부 인증: RealTime 이 전달한 키 검증(쿼리 또는 헤더).
42+
expected = settings.core_internal_api_key
43+
provided = api_key or ws.headers.get("x-internal-api-key")
44+
if expected and provided != expected:
45+
await ws.close(code=4401)
46+
return
47+
48+
await ws.accept()
49+
provider = _provider(ws)
50+
publisher = _publisher(ws)
51+
session = provider.open_session(content_type=content_type, language=None)
52+
await session.start()
53+
54+
async def pump_transcripts() -> None:
55+
async for ev in session.events():
56+
payload = {
57+
"type": "transcript.final" if ev.is_final else "transcript.partial",
58+
"text": ev.text,
59+
}
60+
if ev.is_final:
61+
payload["messageId"] = message_id
62+
try:
63+
await ws.send_json(payload)
64+
except Exception: # noqa: BLE001
65+
return
66+
67+
pump = asyncio.create_task(pump_transcripts())
68+
stopped = False
69+
try:
70+
while True:
71+
msg = await ws.receive()
72+
if msg["type"] == "websocket.disconnect":
73+
break
74+
if msg.get("bytes") is not None:
75+
await session.push(msg["bytes"])
76+
elif msg.get("text") is not None and '"stop"' in msg["text"]:
77+
stopped = True
78+
break
79+
except WebSocketDisconnect:
80+
pass
81+
finally:
82+
await session.finish()
83+
# 남은 최종 이벤트 flush. Deepgram 등 upstream 이 CloseStream 에 무응답이면
84+
# pump(events 생성기 소진)이 영구 대기할 수 있으므로 timeout 으로 상한.
85+
try:
86+
await asyncio.wait_for(pump, timeout=10.0)
87+
except asyncio.TimeoutError:
88+
pump.cancel()
89+
log.warn("voice_stream.pump.timeout", session_id=session_id, message_id=message_id)
90+
result = await session.result()
91+
await session.close()
92+
93+
if result.text.strip():
94+
metrics = analyze(result, filler_pattern=settings.voice_filler_pattern)
95+
cb = VoiceCallbackPayload(
96+
session_id=session_id,
97+
interview_message_id=message_id,
98+
transcript=result.text,
99+
speaking_rate_wpm=metrics.speaking_rate_wpm,
100+
silence_duration_sec=metrics.silence_duration_sec,
101+
filler_word_counts=metrics.filler_word_counts,
102+
pronunciation_accuracy=metrics.pronunciation_accuracy,
103+
error_code=None,
104+
)
105+
else:
106+
cb = VoiceCallbackPayload(
107+
session_id=session_id,
108+
interview_message_id=message_id,
109+
transcript=None,
110+
filler_word_counts={},
111+
error_code="TRANSCRIPTION_EMPTY",
112+
)
113+
try:
114+
await publisher.publish(
115+
routing_key=settings.ai_callback_routing_voice,
116+
message_type="callback.voice",
117+
payload=cb,
118+
trace_id=f"voice-stream-{session_id}-{message_id}",
119+
correlation_id=f"voice-stream-{message_id}",
120+
context=MessageContext(session_id=session_id),
121+
)
122+
log.info(
123+
"voice_stream.callback.published",
124+
session_id=session_id,
125+
interview_message_id=message_id,
126+
stopped=stopped,
127+
chars=len(result.text or ""),
128+
)
129+
except Exception as exc: # noqa: BLE001
130+
log.error(
131+
"voice_stream.callback.failed", error=str(exc), session_id=session_id
132+
)
133+
try:
134+
await ws.close()
135+
except Exception: # noqa: BLE001
136+
pass

ai/src/ai_server/config/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ class Settings(BaseSettings):
5555
deepgram_language: str = "ko"
5656
deepgram_timeout_sec: float = 60.0
5757

58+
# 스트리밍 STT (실시간 음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock.
59+
live_stt_provider: Literal["auto", "mock", "deepgram_live"] = "auto"
60+
deepgram_live_url: str = "wss://api.deepgram.com/v1/listen"
61+
deepgram_live_model: str = "nova-2" # 스트리밍은 nova-2(저지연). 한국어 지원.
62+
deepgram_live_language: str = "ko"
63+
deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end
64+
voice_stream_internal_path: str = "/internal/voice/stream"
65+
5866
# TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock.
5967
tts_provider: Literal["auto", "mock", "openai"] = "auto"
6068
openai_tts_model: str = "gpt-4o-mini-tts"

ai/src/ai_server/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from fastapi import FastAPI
66

77
from ai_server.api.health import router as health_router
8+
from ai_server.api.voice_stream import router as voice_stream_router
89
from ai_server.config.settings import Settings, get_settings
910
from ai_server.messaging.runner import MessagingRuntime
11+
from ai_server.voice.stt.live_factory import build_live_stt_provider
1012

1113
log = structlog.get_logger(__name__)
1214

@@ -18,6 +20,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
1820
app.state.messaging = runtime
1921
try:
2022
await runtime.start()
23+
app.state.settings = settings
24+
app.state.live_stt_provider = build_live_stt_provider(settings)
25+
app.state.callback_publisher = runtime.publisher
2126
yield
2227
finally:
2328
await runtime.stop()
@@ -36,6 +41,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
3641
)
3742

3843
app.include_router(health_router)
44+
app.include_router(voice_stream_router)
3945

4046
return app
4147

ai/src/ai_server/messaging/runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ def __init__(self, settings: Settings) -> None:
226226

227227
self._consumers: list[tuple[AbstractRobustQueue, str]] = []
228228

229+
@property
230+
def publisher(self) -> CallbackPublisher:
231+
return self._publisher
232+
229233
async def start(self) -> None:
230234
await self._connection.open()
231235
await self._publisher.open()
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
import math
6+
from collections.abc import AsyncIterator
7+
from typing import Any
8+
9+
import structlog
10+
import websockets
11+
12+
from ai_server.voice.stt.base import TranscriptionResult, TranscriptionSegment
13+
from ai_server.voice.stt.live import LiveSttProvider, LiveSttSession, LiveTranscriptEvent
14+
15+
log = structlog.get_logger(__name__)
16+
17+
18+
class _DeepgramLiveSession(LiveSttSession):
19+
def __init__(self, *, api_key: str, url: str, model: str, language: str,
20+
endpointing_ms: int, content_type: str) -> None:
21+
self._api_key = api_key
22+
self._url = url
23+
self._model = model
24+
self._language = language
25+
self._endpointing_ms = endpointing_ms
26+
self._content_type = content_type
27+
self._ws: Any | None = None
28+
self._queue: asyncio.Queue[LiveTranscriptEvent | None] = asyncio.Queue()
29+
self._recv_task: asyncio.Task[None] | None = None
30+
self._finals: list[str] = []
31+
self._segments: list[TranscriptionSegment] = []
32+
33+
def _query(self) -> str:
34+
# encoding/sample_rate 는 컨테이너(webm/opus)면 Deepgram 이 자동 디코드.
35+
params = {
36+
"model": self._model,
37+
"language": self._language,
38+
"smart_format": "true",
39+
"interim_results": "true",
40+
# endpointing: N ms 무음 후 speech_final=true 로 턴 종료 신호 (Deepgram 공식).
41+
"endpointing": str(self._endpointing_ms),
42+
# utterance_end_ms: 별도 UtteranceEnd 메시지 backstop (interim_results 필요, 최소 1000).
43+
"utterance_end_ms": str(max(1000, self._endpointing_ms)),
44+
"vad_events": "true",
45+
}
46+
return self._url + "?" + "&".join(f"{k}={v}" for k, v in params.items())
47+
48+
async def start(self) -> None:
49+
self._ws = await websockets.connect(
50+
self._query(),
51+
additional_headers={"Authorization": f"Token {self._api_key}"},
52+
)
53+
self._recv_task = asyncio.create_task(self._recv_loop())
54+
55+
async def _recv_loop(self) -> None:
56+
assert self._ws is not None
57+
try:
58+
async for raw in self._ws:
59+
msg = json.loads(raw)
60+
mtype = msg.get("type")
61+
if mtype == "Results":
62+
alt = (((msg.get("channel") or {}).get("alternatives") or [{}])[0])
63+
text = str(alt.get("transcript") or "")
64+
is_final = bool(msg.get("is_final"))
65+
speech_final = bool(msg.get("speech_final"))
66+
if text:
67+
await self._queue.put(
68+
LiveTranscriptEvent(
69+
text=text, is_final=is_final, speech_final=speech_final
70+
)
71+
)
72+
if is_final and text:
73+
self._finals.append(text)
74+
start = float(msg.get("start", 0.0))
75+
dur = float(msg.get("duration", 0.0))
76+
conf = alt.get("confidence")
77+
self._segments.append(
78+
TranscriptionSegment(
79+
start_sec=start,
80+
end_sec=start + dur,
81+
text=text,
82+
avg_logprob=_conf_to_logprob(conf),
83+
)
84+
)
85+
elif mtype == "UtteranceEnd":
86+
await self._queue.put(
87+
LiveTranscriptEvent(text="", is_final=True, speech_final=True)
88+
)
89+
except Exception as exc: # noqa: BLE001
90+
log.warn("deepgram_live.recv.closed", error=str(exc))
91+
finally:
92+
await self._queue.put(None)
93+
94+
async def push(self, chunk: bytes) -> None:
95+
if self._ws is not None:
96+
await self._ws.send(chunk)
97+
98+
async def finish(self) -> None:
99+
if self._ws is not None:
100+
await self._ws.send(json.dumps({"type": "CloseStream"}))
101+
102+
async def events(self) -> AsyncIterator[LiveTranscriptEvent]:
103+
while True:
104+
ev = await self._queue.get()
105+
if ev is None:
106+
return
107+
yield ev
108+
109+
async def result(self) -> TranscriptionResult:
110+
text = " ".join(self._finals).strip()
111+
dur = self._segments[-1].end_sec if self._segments else None
112+
return TranscriptionResult(
113+
text=text,
114+
language=self._language,
115+
duration_sec=dur,
116+
segments=list(self._segments),
117+
)
118+
119+
async def close(self) -> None:
120+
if self._recv_task is not None:
121+
self._recv_task.cancel()
122+
if self._ws is not None:
123+
await self._ws.close()
124+
125+
126+
class DeepgramLiveSttProvider(LiveSttProvider):
127+
model_name = "deepgram-live"
128+
129+
def __init__(self, *, api_key: str, url: str, model: str, language: str,
130+
endpointing_ms: int) -> None:
131+
if not api_key:
132+
raise ValueError("Deepgram API key 누락")
133+
self._api_key = api_key
134+
self._url = url
135+
self._model = model
136+
self._language = language
137+
self._endpointing_ms = endpointing_ms
138+
139+
def open_session(self, *, content_type: str, language: str | None) -> LiveSttSession:
140+
return _DeepgramLiveSession(
141+
api_key=self._api_key,
142+
url=self._url,
143+
model=self._model,
144+
language=language or self._language,
145+
endpointing_ms=self._endpointing_ms,
146+
content_type=content_type,
147+
)
148+
149+
150+
def _conf_to_logprob(confidence: Any) -> float | None:
151+
if confidence is None:
152+
return None
153+
try:
154+
c = float(confidence)
155+
except (TypeError, ValueError):
156+
return None
157+
return math.log(min(c, 1.0)) if c > 0 else None

0 commit comments

Comments
 (0)