콘텐츠로 이동

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)가 다음 메시지를 보냅니다.

  1. 첫 샘플이 출력 track에 들어갈 때:
{ "type": "tts_playback_state", "active": true }
  1. 자연 재생 완료 시:
{ "type": "tts_playback_state", "active": false }
{ "type": "tts_playback_completed", "generation": 42 }

tts_playback_completed는 실제 재생 종료와 무응답 관찰 시작 신호입니다. 오래된 generation이나 바지인으로 중단된 재생에는 보내지 마세요. 중단 시에는 active:false만 보내 재생 상태를 해제합니다.

  1. 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가 후보를 취소할 수 있습니다.

  1. speech_candidate_cancelled면 같은 activityId에 대해서만 원래 볼륨으로 복귀합니다.
  2. speech_started면 확정된 바지인이므로 해당 generation의 재생과 대기 큐를 즉시 중단합니다.
  3. OpenAI Realtime의 realtime_playback_interrupt에서는 재생을 멈추고, 실제 들린 위치를 알 수 있을 때 다음 메시지를 보냅니다.
{
  "type": "realtime_playback_truncated",
  "itemId": "provider-item-id",
  "audioEndMs": 860
}

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 순서

  1. API key 또는 에페메랄 JWT를 준비합니다.
  2. /sohri에 query 협상값과 credential을 실어 연결합니다.
  3. connection_established를 기다리고 sessionId, dialogId, ttsStream을 저장합니다.
  4. 첫 PCM 전에 conversation_session_update를 보내고 conversation_session_updated를 기다립니다.
  5. 16 kHz mono PCM s16le를 약 100 ms binary frame으로 지속 전송합니다.
  6. speech_candidate면 실제 출력에 duck을 적용하고 tts_duck_applied를 보냅니다.
  7. speech_started면 이전 TTS를 중단하고, stt_result와 dm_processing을 UI에 반영합니다.
  8. 레거시 WAV 또는 스트리밍 PCM을 generation별로 재생합니다.
  9. 실제 재생 시작·종료에 tts_playback_state, 자연 완료에 tts_playback_completed를 보냅니다.
  10. dialog_response로 턴을 마감하고 다음 입력을 계속합니다.
  11. 종료 시 DIALOG_END를 보내고 필요한 결과를 받은 뒤 close합니다.

구현 중 이벤트 순서가 불명확하면 일반 턴 흐름, 상세 이벤트는 이벤트 참조, TTS 큐는 TTS 스트리밍, duck과 취소는 바지인을 기준으로 판단하세요.