본문으로 건너뛰기

실시간 음성 채팅

TanStack AI는 음성 대 음성 AI 상호작용을 구축할 수 있는 완전한 실시간 음성 채팅 시스템을 제공합니다. 실시간 API는 여러 provider(OpenAI, ElevenLabs), 자동 도구 실행, 오디오 시각화, 이미지가 포함된 멀티모달 입력을 지원합니다.

개요

실시간 음성 채팅은 텍스트 기반 채팅과 다음과 같은 몇 가지 중요한 차이가 있습니다.

  • 양방향 오디오 - 사용자가 마이크에 대고 말하면 AI가 합성 음성으로 응답합니다.
  • 음성 활동 감지(VAD) - 사용자가 말하기 시작하고 멈추는 시점을 자동으로 감지합니다.
  • 인터럽트 - 사용자가 AI의 응답 중간에 인터럽트를 발생시킬 수 있습니다.
  • 낮은 지연 시간 - WebRTC 또는 WebSocket 연결을 사용해 거의 즉각적으로 통신합니다.
  • 멀티모달 - 음성과 함께 텍스트 입력, 이미지 입력, 도구 호출을 지원합니다.

실시간 시스템은 다른 TanStack AI 구성 요소와 동일한 어댑터 아키텍처를 따릅니다.

  1. 서버는 provider별 토큰 어댑터와 함께 realtimeToken()을 사용해 임시 토큰을 생성합니다.
  2. 클라이언트는 provider별 연결 어댑터와 함께 RealtimeClient(React에서는 useRealtimeChat)를 사용해 연결합니다.
  3. Provider 어댑터는 OpenAI WebRTC, ElevenLabs WebSocket 등의 프로토콜 차이를 처리합니다.

빠른 시작

1. 서버 토큰 엔드포인트 설정

서버는 API 키가 클라이언트에 절대 전달되지 않도록 수명이 짧은 토큰을 생성합니다.

import { realtimeToken } from '@tanstack/ai'
import { openaiRealtimeToken } from '@tanstack/ai-openai'
import { createServerFn } from '@tanstack/react-start'

const getRealtimeToken = createServerFn({ method: 'POST' })
.handler(async () => {
return realtimeToken({
adapter: openaiRealtimeToken({
model: 'gpt-realtime',
}),
})
})

참고: realtimeToken() 함수는 모든 서버 프레임워크에서 작동합니다. 위 예제에서는 TanStack Start를 사용하지만, HTTP 요청을 처리할 수 있는 Express, Hono, Fastify 또는 다른 프레임워크를 사용할 수 있습니다.

2. 클라이언트에서 연결(React)

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'

function VoiceChat() {
const {
status,
mode,
messages,
connect,
disconnect,
pendingUserTranscript,
pendingAssistantTranscript,
inputLevel,
outputLevel,
} = useRealtimeChat({
getToken: () => fetch('/api/realtime-token', { method: 'POST' }).then(r => r.json()),
adapter: openaiRealtime(),
instructions: 'You are a helpful voice assistant.',
voice: 'alloy',
})

return (
<div>
<p>Status: {status}</p>
<p>Mode: {mode}</p>
<button onClick={status === 'idle' ? connect : disconnect}>
{status === 'idle' ? 'Start Conversation' : 'End Conversation'}
</button>
{pendingUserTranscript && <p>You: {pendingUserTranscript}...</p>}
{pendingAssistantTranscript && <p>AI: {pendingAssistantTranscript}...</p>}
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) => (
<span key={i}>
{part.type === 'text' ? part.content : null}
{part.type === 'audio' ? part.transcript : null}
</span>
))}
</div>
))}
</div>
)
}

프로바이더

OpenAI Realtime

OpenAI의 실시간 API는 지연 시간이 짧은 음성 통신을 위해 WebRTC를 사용합니다.

서버(토큰 생성):

import { realtimeToken } from '@tanstack/ai'
import { openaiRealtimeToken } from '@tanstack/ai-openai'

const token = await realtimeToken({
adapter: openaiRealtimeToken({
model: 'gpt-realtime',
}),
})

클라이언트(연결):

import { openaiRealtime } from '@tanstack/ai-openai'

const adapter = openaiRealtime()

환경 변수: OPENAI_API_KEY

사용 가능한 모델:

모델설명
gpt-realtime완전한 실시간 모델
gpt-realtime-mini더 작고 빠른 실시간 모델

사용 가능한 음성: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar

ElevenLabs Realtime

ElevenLabs는 WebSocket 연결을 사용하며 대시보드에 에이전트를 구성해야 합니다.

서버(토큰 생성):

import { realtimeToken } from '@tanstack/ai'
import { elevenlabsRealtimeToken } from '@tanstack/ai-elevenlabs'

const token = await realtimeToken({
adapter: elevenlabsRealtimeToken({
agentId: 'your-agent-id',
}),
})

클라이언트(연결):

import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'

const adapter = elevenlabsRealtime()

환경 변수: ELEVENLABS_API_KEY, ELEVENLABS_AGENT_ID(선택 사항)

음성 활동 감지(VAD)

VAD는 시스템이 말하기 시작하고 멈췄다고 감지하는 시점을 제어합니다. 세 가지 모드를 사용할 수 있습니다.

모드작동 방식적합한 경우
serverprovider가 오디오 에너지 수준을 사용해 서버 측에서 음성을 감지합니다.기본값 — 간단하며 클라이언트 복잡도가 낮습니다.
semantic일시 중지와 문장 완성 같은 의미적 단서를 사용해 발화 종료를 감지합니다.자연스러운 대화 — 문장 중간에 끊는 것을 방지합니다.
manualstartListening() / stopListening()을 명시적으로 호출합니다.푸시 투 토크 인터페이스

훅을 생성할 때 VAD 모드를 설정합니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'

const { startListening, stopListening, updateSession } = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
vadMode: 'manual', // or 'server' or 'semantic'
})

manual VAD 모드에서는 푸시 투 토크 방식의 상호작용을 사용합니다.

<button onMouseDown={startListening} onMouseUp={stopListening}>
Hold to talk
</button>

updateSession을 사용하면 다시 연결하지 않고 런타임에 VAD 모드를 전환할 수 있습니다.

updateSession({ vadMode: 'semantic' })

semantic VAD에서는 eagerness를 구성해 모델이 발화를 마쳤다고 판단하기 전에 기다리는 시간을 제어합니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'

const chat = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
vadMode: 'semantic',
semanticEagerness: 'low', // waits longer before detecting end-of-speech
})

도구

실시간 세션은 클라이언트 측 도구를 지원합니다. 표준 toolDefinition() API를 사용해 도구를 정의하고 클라이언트 구현을 전달합니다.

import { toolDefinition } from '@tanstack/ai'
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
import { z } from 'zod'

const getWeatherDef = toolDefinition({
name: 'getWeather',
description: 'Get weather for a location',
inputSchema: z.object({
location: z.string().meta({ description: 'City name' }),
}),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
})

const getWeather = getWeatherDef.client(async ({ location }) => {
const res = await fetch(`/api/weather?location=${location}`)
return res.json()
})

// Pass tools to the hook
const chat = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
tools: [getWeather],
})

실시간 클라이언트는 도구 호출을 자동으로 실행하고 결과를 provider에 다시 전송합니다. 도구 호출은 메시지에서 tool-calltool-result 파트로 표시됩니다.

텍스트 및 이미지 입력

음성 외에도 텍스트 메시지와 이미지를 보낼 수 있습니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
import { base64ImageData } from './assets'

const { sendText, sendImage } = useRealtimeChat({ getToken, adapter: openaiRealtime() })

// Send a text message
sendText('What is the weather like today?')

// Send an image (base64 data or URL)
sendImage(base64ImageData, 'image/png')

오디오 시각화

useRealtimeChat은 레벨 미터, 파형, 스펙트럼 분석기를 구축할 수 있도록 오디오 분석 데이터를 제공합니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'

const {
inputLevel, // 0–1 normalized microphone level
outputLevel, // 0–1 normalized speaker level
getInputFrequencyData, // Uint8Array — FFT bins for spectrum analyzer
getOutputFrequencyData,
getInputTimeDomainData, // Uint8Array — waveform samples for oscilloscope
getOutputTimeDomainData,
} = useRealtimeChat({ getToken, adapter: openaiRealtime() })

연결된 동안 inputLeveloutputLevel 값은 모든 애니메이션 프레임에서 업데이트되므로 CSS 애니메이션이나 canvas 시각화를 구동하는 데 적합합니다.

간단한 레벨 미터:

<div style={{ width: `${inputLevel * 100}%`, height: 4, background: 'green' }} />

맥동하는 오디오 표시기:

function AudioIndicator({ level }: { level: number }) {
return (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
transform: `scale(${1 + level * 0.5})`,
backgroundColor: `rgba(59, 130, 246, ${0.3 + level * 0.7})`,
transition: 'transform 0.1s ease',
}}
/>
)
}

canvas를 사용하는 스펙트럼 분석기:

function drawSpectrum(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d')!
const draw = () => {
const data = getInputFrequencyData()
ctx.clearRect(0, 0, canvas.width, canvas.height)
const barWidth = canvas.width / data.length
data.forEach((value, i) => {
const height = (value / 255) * canvas.height
ctx.fillRect(i * barWidth, canvas.height - height, barWidth - 1, height)
})
requestAnimationFrame(draw)
}
draw()
}

인터럽트

사용자는 AI가 말하는 동안 인터럽트를 발생시킬 수 있습니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'

const { interrupt, mode } = useRealtimeChat({ getToken, adapter: openaiRealtime() })

// Programmatically interrupt
if (mode === 'speaking') {
interrupt()
}

server 또는 semantic VAD에서는 사용자가 말하기 시작할 때 인터럽트가 자동으로 발생합니다. 인터럽트된 메시지는 메시지 배열에서 interrupted: true로 표시됩니다.

RealtimeClient 직접 사용

React를 사용하지 않는 애플리케이션이나 더 세밀한 제어가 필요한 경우 RealtimeClient를 직접 사용합니다.

import { RealtimeClient } from '@tanstack/ai-client'
import { openaiRealtime } from '@tanstack/ai-openai'

const client = new RealtimeClient({
getToken: () => fetch('/api/realtime-token', { method: 'POST' }).then(r => r.json()),
adapter: openaiRealtime(),
instructions: 'You are a helpful assistant.',
voice: 'alloy',
onMessage: (message) => {
console.log(`${message.role}:`, message.parts)
},
onStatusChange: (status) => {
console.log('Status:', status)
},
onModeChange: (mode) => {
console.log('Mode:', mode)
},
})

// Connect
await client.connect()

// Send text
client.sendText('Hello!')

// Subscribe to state changes
const unsub = client.onStateChange((state) => {
console.log('Messages:', state.messages.length)
})

// Disconnect when done
await client.disconnect()

// Clean up
client.destroy()

ElevenLabs 사용

TanStack AI는 대체 실시간 음성 provider로 ElevenLabs를 지원합니다. 클라이언트 API는 동일하므로 어댑터와 토큰 함수만 교체하면 됩니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'

const { status, messages, connect, disconnect } = useRealtimeChat({
getToken: () => fetch('/api/elevenlabs-token').then(r => r.json()),
adapter: elevenlabsRealtime(),
})

참고: ElevenLabs는 에이전트 기반 구성을 사용하므로 음성과 시스템 프롬프트는 ElevenLabs 대시보드 또는 토큰 재정의를 통해 설정합니다. 설정 방법은 ElevenLabs 어댑터 페이지를 참고합니다.

다음 단계

고급 사용법

이 기능을 작동시키는 데 필요하지 않은 참고 세부 정보입니다.

세션 구성

훅 옵션을 통해 실시간 세션을 구성합니다.

옵션타입기본값설명
getToken() => Promise<RealtimeToken>필수서버에서 토큰을 가져오는 함수
adapterRealtimeAdapter필수Provider 어댑터(openaiRealtime(), elevenlabsRealtime())
instructionsstring어시스턴트를 위한 시스템 지침
voicestring오디오 출력에 사용할 음성
toolsAnyClientTool[]실행 로직이 포함된 클라이언트 측 도구
vadMode'server' | 'semantic' | 'manual''server'음성 활동 감지 모드
semanticEagerness'low' | 'medium' | 'high'semantic VAD의 eagerness
autoPlaybackbooleantrue어시스턴트 오디오 자동 재생
autoCapturebooleantrue연결할 때 마이크 요청
outputModalitiesArray<'audio' | 'text'>응답 모달리티
temperaturenumber생성 temperature
maxOutputTokensnumber | 'inf'응답의 최대 토큰 수

연결 수명 주기

실시간 클라이언트는 다음 상태로 연결 수명 주기를 관리합니다.

상태설명
idle연결되지 않음
connecting연결 설정 중
connected활성 세션
reconnecting인터럽트 후 다시 연결 중
error연결 오류 발생

연결 중에는 다음 모드를 사용합니다.

모드설명
idle연결되었지만 상호작용하지 않음
listening사용자 오디오 입력 캡처 중
thinking사용자 입력 처리 중
speakingAI가 응답 생성 중
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
import { useEffect } from 'react'

const { status, mode, error, connect, disconnect } = useRealtimeChat({ getToken, adapter: openaiRealtime() })

// Handle connection
useEffect(() => {
if (status === 'error' && error) {
console.error('Connection error:', error.message)
}
}, [status, error])

메시지 구조

실시간 메시지는 UIMessage와 유사한 parts 기반 구조를 사용합니다.

import type { RealtimeMessagePart } from '@tanstack/ai'

interface RealtimeMessage {
id: string
role: 'user' | 'assistant'
timestamp: number
parts: Array<RealtimeMessagePart>
interrupted?: boolean
}

각 파트는 다음 중 하나일 수 있습니다.

파트 타입필드설명
textcontentsendText()로 보낸 텍스트 콘텐츠
audiotranscript, durationMs전사된 음성 콘텐츠
tool-callid, name, arguments, input, output도구 호출
tool-resulttoolCallId, content도구 실행 결과
imagedata, mimeTypesendImage()로 보낸 이미지

오류 처리

onError 콜백 또는 error 상태를 통해 오류를 처리합니다.

import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'

const { error } = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
onError: (err: Error) => {
if (err.message.includes('Permission denied')) {
alert('Microphone access is required for voice chat.')
} else {
console.error('Realtime error:', err)
}
},
})

모범 사례

  1. 토큰 보안 - 항상 서버 측에서 토큰을 생성합니다. API 키를 클라이언트에 절대 노출하지 않습니다.
  2. 마이크 권한 - 사용자가 마이크 액세스를 거부하는 경우를 적절히 처리합니다.
  3. 정리 - 컴포넌트를 마운트 해제할 때 항상 연결을 해제합니다. useRealtimeChat 훅이 이를 자동으로 처리합니다.
  4. 지침 - 음성 어시스턴트 지침을 간결하게 유지합니다. 응답이 대화형으로 유지되도록 모델에 음성 인터페이스라는 점을 상기시킵니다.
  5. 도구 설계 - 결과가 실시간으로 처리되므로 도구 설명은 명확하게, 도구 출력은 작게 유지합니다.
  6. 오류 복구 - 일시적인 연결 실패에 대한 재시도 로직을 구현합니다.