Socket Backend 클라이언트 연동 가이드¶
이 문서는 클라이언트 또는 중계 서비스가 Socket Backend의 /sohri WebSocket에 연결해
음성 대화를 주고받는 최소 구현 절차를 설명합니다. 전체 필드 정의는
WebSocket 계약과 이벤트 참조를 함께 확인하세요.
1. 직접 연결과 WebRTC Gateway 선택¶
| 연결 방식 | 적합한 경우 | 구현 책임 |
|---|---|---|
| Socket Backend 직접 연결 | 서버 간 연동, 네이티브 앱, 통제된 브라우저 데모처럼 16 kHz PCM 생성과 재생을 직접 관리할 수 있을 때 | 인증, PCM 리샘플링·청킹, TTS 재생 큐, 재연결, 재생·바지인 ACK |
| WebRTC Gateway 경유 | 브라우저·모바일의 실시간 미디어, NAT 통과, Opus/jitter 처리, 여러 단말, 정확한 재생 track 기준 duck·barge-in이 필요할 때 | 단말과는 WebRTC, Socket Backend와는 /sohri; PCM 변환과 실제 재생 시점 ACK를 Gateway가 담당 |
브라우저에 장기 워크스페이스 API key를 배포해야 한다면 직접 연결을 선택하면 안 됩니다. 브라우저는 App Backend가 발급한 짧은 수명의 세션 토큰으로 직접 연결하거나, credential을 보관하는 Gateway를 통해 연결하는 구성이 안전합니다.
기본 sohri 대화 경로의 내부 호출 순서는 다음과 같습니다.
Client/Gateway → Socket Backend → EPD(발화 감지 + STT)
→ Graph Builder(Dialog Manager)
→ Skill Runtime
→ Sohri Skill Engine
→ TTS
Socket Backend가 Sohri Skill Engine을 직접 호출하는 구조가 아닙니다. Graph Builder가 대화를 진행하고, 필요한 skill을 Skill Runtime을 거쳐 Sohri Skill Engine에 실행합니다. 엔진별 상세 차이는 대화 경로 선택을 참고하세요.
2. 사전 준비와 credential 선택¶
필수 준비 사항은 다음과 같습니다.
- 환경별
wss://<host>/sohri주소와 허용 Origin - 워크스페이스 API key 또는 App Backend가 발급한 Ed25519 세션 에페메랄 JWT
- 입력 오디오를 16 kHz, mono, signed 16-bit little-endian raw PCM으로 만드는 처리
- 서버의 JSON text frame과 TTS binary frame을 함께 처리하는 수신 루프
- 실제 오디오 출력 시점을 기준으로 재생·duck·바지인 상태를 회신하는 로직
credential은 다음 기준으로 고릅니다.
- 서버, Gateway, 신뢰할 수 있는 네이티브 클라이언트:
Authorization: Bearer <credential>을 우선 사용합니다. - 워크스페이스 소유 서비스:
sr_live_...또는sr_test_...API key를 사용합니다. - 로그인한 최종 사용자: App Backend가 발급한 짧은 수명의 JWT를 사용합니다. Socket Backend는
aud=sohri-realtime,iss=sohri-app-backend, 만료, 서명,sub,workspaceId를 검증합니다. - 브라우저 직접 연결: 커스텀
Authorization헤더를 설정할 수 없으므로 WebSocket subprotocol을 사용합니다. 장기 API key보다 에페메랄 JWT를 권장합니다. ?api_key=와?token=은 폴백입니다. URL은 프록시·접속 로그·브라우저 기록에 남을 수 있으므로 운영 기본 방식으로 사용하지 마세요.
인증 우선순위와 401/403 구분은 연결과 인증에 정리되어 있습니다.
3. /sohri 핸드셰이크¶
서버 또는 Gateway¶
아래는 Node.js ws 스타일의 예시입니다. 사용하는 라이브러리의 TLS 검증과 프록시 설정은 별도로
적용해야 합니다.
import WebSocket from "ws";
const url = new URL("wss://socket.example.com/sohri");
url.searchParams.set("continuousAudio", "true");
url.searchParams.set("ttsSampleRate", "48000");
url.searchParams.set("ttsStream", "v1");
const ws = new WebSocket(url, {
headers: { Authorization: `Bearer ${process.env.SOHRI_CREDENTIAL}` },
});
브라우저¶
브라우저의 표준 WebSocket 생성자는 임의의 HTTP 헤더를 받지 않습니다. 다음처럼 marker와
credential을 subprotocol 배열로 전달해야 합니다.
const url = new URL("wss://socket.example.com/sohri");
url.searchParams.set("ttsStream", "v1");
const token = await fetchEphemeralTokenFromAppBackend();
const ws = new WebSocket(url, ["sohri.token", token]);
ws.binaryType = "arraybuffer";
ws.addEventListener("open", () => {
// 서버는 marker 하나를 선택해 에코합니다.
if (ws.protocol !== "sohri.token") ws.close(1008, "unexpected subprotocol");
});
API key를 꼭 써야 하는 통제된 브라우저라면 ["sohri.apikey", apiKey] 형식도 지원됩니다. 이 방식에서
서버는 credential이 아니라 sohri.token 또는 sohri.apikey marker를
Sec-WebSocket-Protocol 응답으로 에코합니다. 전달값은 브라우저가 허용하는 subprotocol token
문법에도 맞아야 합니다.
query 협상¶
대표 연결 URL은 다음과 같습니다.
wss://<host>/sohri?minSpeech=0.3&endSilence=1.2&epdTimeout=7.0&continuousAudio=true&ttsSampleRate=48000&ttsStream=v1
| query | 용도 |
|---|---|
minSpeech |
EPD가 발화 후보를 인정할 최소 음성 길이 |
endSilence |
발화 종료로 판단할 무음 길이 |
epdTimeout |
EPD activity 최대 대기 시간 |
continuousAudio |
TIMEOUT 뒤 다음 PCM에서 EPD 연결을 이어갈지 여부. 연속 송신 Gateway에 권장 |
ttsSampleRate |
TTS 수신측 상한 16000, 24000, 48000. 실제 출력은 provider 능력에 따라 낮아질 수 있음 |
ttsStream |
v1이면 스트리밍 요청. 서버도 활성화된 경우에만 성립 |
EPD 값은 초 단위이며 최종적으로 minSpeech <= epdTimeout이어야 합니다. 협상 세부 범위와 별칭은
WebSocket 계약을 확인하세요.
WebSocket open만으로 PCM을 보내지 말고, 먼저 다음 ACK를 기다립니다.
{
"type": "connection_established",
"sessionId": "websocket-session-id",
"dialogId": "dialog-uuid",
"ttsStream": "v1",
"timestamp": 1710000000000
}
이 이벤트는 data가 없는 평평한 형태입니다. dialogId는 대화 행 생성 실패 시, ttsStream은
스트리밍 협상 미성립 시 생략됩니다. 요청 URL에 ttsStream=v1을 넣었더라도 ACK에
ttsStream:"v1"이 없으면 레거시 WAV 경로를 처리해야 합니다.
4. 대화 엔진 확정¶
conversation_session_update는 선택 사항이지만 첫 PCM보다 먼저 보내는 것을 권장합니다.
ws.send(JSON.stringify({
type: "conversation_session_update",
conversationEngine: "sohri",
externalUser: { id: "customer-123" }
}));
sohri: EPD → Graph Builder → TTS 경로openai_realtime: OpenAI Realtime의 VAD·STT·대화·음성 경로- 설정 없이 첫 binary PCM을 보내면
sohri로 잠깁니다. - 엔진은 확정 후 다른 값으로 바꿀 수 없습니다.
openai_realtime은 upstream 준비가 끝난 뒤에야conversation_session_updated를 보냅니다. 이 ACK 전에는 PCM을 보내지 마세요.instructions는openai_realtime전용이며 서버 설정과 API-key 세션 조건에 따라 거절될 수 있으므로 일반 클라이언트가 가정해서는 안 됩니다.
성공 ACK 예시는 다음과 같습니다.
{
"type": "conversation_session_updated",
"sessionId": "websocket-session-id",
"conversationEngine": "sohri"
}
5. 입력 PCM 전송¶
입력 binary frame은 WAV나 WebM이 아니라 16 kHz, mono, PCM s16le입니다. 한 샘플은 2바이트이며 프레임 경계에서 샘플을 자르지 마세요. 약 100 ms, 즉 1,600 samples/3,200 bytes 단위 전송이 권장됩니다. 지나치게 큰 프레임으로 지연을 늘리거나, 마이크 콜백마다 지나치게 작은 프레임을 보내지 마세요.
브라우저의 getUserMedia({audio:{sampleRate:16000}})는 16 kHz를 보장하지 않습니다. 실제
AudioContext.sampleRate를 확인하고 필요하면 리샘플링한 뒤 변환해야 합니다.
function floatToPcm16le(float32) {
const out = new Int16Array(float32.length);
for (let i = 0; i < float32.length; i++) {
const x = Math.max(-1, Math.min(1, float32[i]));
out[i] = x < 0 ? Math.round(x * 0x8000) : Math.round(x * 0x7fff);
}
return out.buffer; // little-endian 플랫폼을 전제로 하지 않으려면 DataView로 명시 작성
}
// pcm16kMono는 리샘플링까지 끝난 Float32Array라고 가정합니다.
if (ws.readyState === WebSocket.OPEN && inputAllowed) {
ws.send(floatToPcm16le(pcm16kMono));
}
플랫폼 byte order가 불명확한 런타임에서는 DataView.setInt16(offset, value, true)로 little-endian을
명시하세요. DM/TTS 처리 중 서버 입력 gate가 PCM을 폐기할 수 있으므로, dialog_response가 오기
전의 중첩 턴을 별도 큐에 저장했다가 재전송하는 방식은 사용하지 마세요.
6. 주요 text 이벤트¶
클라이언트 → 서버의 핵심 이벤트는 다음과 같습니다.
type |
용도 |
|---|---|
conversation_session_update |
첫 PCM 전 엔진과 사용자 컨텍스트 확정 |
text |
SOHRI 텍스트 턴 |
start_conversation |
사용자 발화 없이 선인사 시작 |
cancel_response |
현재 assistant 출력 무효화 |
epd_config_update |
다음 EPD 연결에 적용할 발화 설정 변경 |
language_selection |
ko, ja, en 선택 |
dm_scenario_update |
시나리오·사용자·TTS 세션 파라미터 설정 |
tts_playback_state |
실제 TTS 출력의 시작·종료 상태 |
tts_playback_completed |
현재 generation이 자연 재생 완료됨 |
tts_duck_applied |
실제 출력 track에 duck을 적용한 경계 ACK |
realtime_playback_truncated |
OpenAI 출력에서 실제 재생한 종료 위치 보고 |
event + event:"DIALOG_END" |
소켓을 즉시 닫지 않고 대화 최종화 요청 |
서버 → 클라이언트에서 우선 처리할 이벤트는 다음과 같습니다.
type |
클라이언트 동작 |
|---|---|
connection_established |
세션 준비 완료, 협상 결과 저장 |
conversation_session_updated |
선택 엔진 준비 완료 후 PCM 송신 시작 |
speech_candidate |
activityId별 duck 적용 후 ACK |
speech_candidate_cancelled |
해당 activity의 duck 해제 |
speech_started |
현재 TTS 큐와 재생을 즉시 중단 |
stt_result |
사용자 자막 표시 |
dm_processing |
응답 생성 중 상태 표시 |
dialog_text |
스트리밍 TTS 시작 직전 assistant 자막 표시 |
tts_audio_metadata |
다음 레거시 WAV binary와 짝지음 |
tts_stream_start / tts_stream_end |
raw PCM 재생 큐 시작·송신 종료 |
dialog_response |
턴 완료 처리. dialog_text와 같은 텍스트를 중복 렌더하지 않음 |
tts_error / dialog_error / error |
soft-fail 또는 계약 오류 처리 |
dialog_complete / dialog_stop_requested / stop_recording |
입력 중단 또는 UI 종료 흐름 반영 |
realtime_event / realtime_playback_interrupt |
OpenAI native 이벤트 처리와 재생 중단 |
응답 봉투는 생산자별로 다릅니다. connection_established와 일부 Realtime 이벤트는 평평하고,
많은 SOHRI 이벤트는 {type,data,timestamp}이므로 다음처럼 정규화하면 안전합니다.
function payloadOf(message) {
return message.data && typeof message.data === "object"
? message.data
: message;
}
전체 목록과 정확한 필드는 이벤트 참조를 기준으로 구현하세요.
7. TTS 재생: 레거시와 스트리밍¶
한 연결에서도 운영 설정 변경에 따라 다음 턴이 레거시로 돌아올 수 있으므로, 견고한 클라이언트는 두 경로를 모두 처리하는 편이 안전합니다.
레거시 WAV¶
순서는 tts_audio_metadata → WAV binary 한 프레임 → dialog_response입니다.
metadata의 generation, byteLength를 큐에 넣고 바로 다음 binary와 짝지으세요. binary에는 WAV
헤더가 있으므로 브라우저 Audio/Web Audio decoder 또는 일반 WAV decoder로 재생할 수 있습니다.
스트리밍 v1¶
SOHRI 순서는 다음과 같습니다.
dialog_text
→ tts_stream_start { generation, format:{encoding, sampleRate, channels}, ... }
→ raw PCM binary × N
→ tts_stream_end { generation, status, ... }
→ dialog_response
binary는 WAV 헤더 없는 PCM s16le입니다. tts_stream_start.data.format을 정본으로 재생 큐를
설정하세요. OpenAI Realtime도 같은 start/binary/end 수명주기를 사용하지만 start가 평평하며
sampleRate, channels, itemId를 직접 포함합니다.
tts_stream_end는 서버 송신이 끝났다는 뜻이지 스피커 재생 완료가 아닙니다. 큐에 남은 PCM이 실제
출력된 뒤에만 tts_playback_completed를 보내세요. status:"aborted"이면 해당 generation의 큐를
즉시 닫고 남은 오디오를 버립니다.
let activeStream = null;
const legacyMetadata = [];
ws.addEventListener("message", async (event) => {
if (typeof event.data === "string") {
const message = JSON.parse(event.data);
const data = payloadOf(message);
if (message.type === "tts_audio_metadata") legacyMetadata.push(data);
if (message.type === "tts_stream_start") {
activeStream = {
generation: data.generation,
sampleRate: data.format?.sampleRate ?? data.sampleRate,
channels: data.format?.channels ?? data.channels ?? 1,
itemId: data.itemId,
};
openPcmQueue(activeStream);
}
if (message.type === "tts_stream_end" &&
activeStream?.generation === data.generation) {
const stream = activeStream;
if (data.status === "completed") {
markQueueInputEnded(stream, () => {
// 실제 출력 큐가 모두 비워진 뒤 호출되는 callback이라고 가정합니다.
if (activeStream === stream) activeStream = null;
reportNaturalPlaybackCompletion(stream.generation);
});
} else {
abortPcmQueue(stream);
activeStream = null;
}
}
return;
}
const bytes = event.data instanceof ArrayBuffer
? event.data
: await event.data.arrayBuffer();
if (activeStream) enqueueRawPcm(activeStream, bytes);
else playWav(bytes, legacyMetadata.shift());
});
실제 구현에서는 metadata의 byteLength, generation 일치, 스트림별 총 바이트 수를 검증하고,
WebSocket 순서는 보장되지만 start와 end 사이에 다른 JSON 이벤트가 들어올 수 있음을 허용해야 합니다.
자세한 취소·길이 계산 규칙은 TTS 스트리밍을 참고하세요.
8. 재생 상태, duck, barge-in ACK¶
실제 오디오 출력을 소유한 구성요소(직접 클라이언트 또는 Gateway)가 다음 메시지를 보냅니다.
- 첫 샘플이 출력 track에 들어갈 때:
- 자연 재생 완료 시:
{ "type": "tts_playback_state", "active": false }
{ "type": "tts_playback_completed", "generation": 42 }
tts_playback_completed는 실제 재생 종료와 무응답 관찰 시작 신호입니다. 오래된 generation이나
바지인으로 중단된 재생에는 보내지 마세요. 중단 시에는 active:false만 보내 재생 상태를 해제합니다.
speech_candidate수신 시 실제 output track에 duck gain을 적용한 뒤 즉시 ACK합니다.
{
"type": "tts_duck_applied",
"activityId": "epd-activity-id",
"generation": 42,
"appliedAt": 1710000000000,
"gain": 0.05,
"gatewayFrameSeq": 99,
"hadRecentPlayback": true,
"initialGraceMs": 0
}
gain은 0..1의 선형 값이며 판정 기준이 아니라 진단값입니다. gatewayFrameSeq는 실제 gain이
적용된 출력 프레임의 단조 증가 시퀀스입니다. ACK가 늦으면 EPD가 후보를 취소할 수 있습니다.
speech_candidate_cancelled면 같은activityId에 대해서만 원래 볼륨으로 복귀합니다.speech_started면 확정된 바지인이므로 해당 generation의 재생과 대기 큐를 즉시 중단합니다.- OpenAI Realtime의
realtime_playback_interrupt에서는 재생을 멈추고, 실제 들린 위치를 알 수 있을 때 다음 메시지를 보냅니다.
activity와 generation이 맞지 않는 늦은 이벤트는 현재 재생 상태를 바꾸지 말고 무시하세요. SOHRI와 OpenAI의 취소 차이, ACK 기한과 generation 의미는 바지인에 자세히 설명되어 있습니다.
9. 오류, 종료, 재연결¶
- 핸드셰이크 인증 실패는 WebSocket이 열리기 전 HTTP
401또는403입니다. 브라우저 API는 이 응답 본문과 상태를 노출하지 않고 일반error/비정상 close로만 보일 수 있으므로, 발급 서버와 Gateway 로그에서 원인을 확인할 수 있게 하세요. - 연결 후
error봉투는 한 종류만 존재하지 않습니다.data.status/code/message형식과data.code/message/reason형식을 모두 허용하고,type을 먼저 판별하세요. tts_error는 저장된 턴을 되돌리지 않는 TTS soft-fail입니다.dialog_error는 해당 턴의 DM 실패입니다. 모든 오류를 소켓 종료로 처리하지 마세요.1000과 사용자가 요청한 종료는 재연결하지 않습니다.1013은 용량 초과이므로 backoff 후 재시도합니다.1001,1006, transport 오류는 네트워크 상태를 확인하며 지수 backoff와 jitter를 적용합니다.- 예:
0.5s → 1s → 2s → 4s → ..., 최대 30초로 제한하고, 안정적으로 연결된 뒤 시도 횟수를 초기화합니다. 인증401/403, 잘못된 설정400/409는 값을 고치기 전 반복 재시도하지 마세요. - 재연결은 resume이 아니라 새 세션입니다. 새
sessionId와dialogId를 받고 엔진 선택, 언어·시나리오 설정을 다시 보내야 합니다. 이전 소켓의 PCM, text 턴, TTS ACK를 자동 재생하지 마세요. - 정상 종료가 필요하면
{ "type":"event", "event":"DIALOG_END" }로 최종화를 요청한 뒤 필요한 분석 완료 이벤트를 기다리고 소켓을 닫습니다. 단순 close도 서버 정리를 시작하지만, 전송 중이던 결과를 클라이언트가 받을 수는 없습니다.
10. 최소 end-to-end 순서¶
- API key 또는 에페메랄 JWT를 준비합니다.
/sohri에 query 협상값과 credential을 실어 연결합니다.connection_established를 기다리고sessionId,dialogId,ttsStream을 저장합니다.- 첫 PCM 전에
conversation_session_update를 보내고conversation_session_updated를 기다립니다. - 16 kHz mono PCM s16le를 약 100 ms binary frame으로 지속 전송합니다.
speech_candidate면 실제 출력에 duck을 적용하고tts_duck_applied를 보냅니다.speech_started면 이전 TTS를 중단하고,stt_result와dm_processing을 UI에 반영합니다.- 레거시 WAV 또는 스트리밍 PCM을 generation별로 재생합니다.
- 실제 재생 시작·종료에
tts_playback_state, 자연 완료에tts_playback_completed를 보냅니다. dialog_response로 턴을 마감하고 다음 입력을 계속합니다.- 종료 시
DIALOG_END를 보내고 필요한 결과를 받은 뒤 close합니다.
구현 중 이벤트 순서가 불명확하면 일반 턴 흐름, 상세 이벤트는 이벤트 참조, TTS 큐는 TTS 스트리밍, duck과 취소는 바지인을 기준으로 판단하세요.