Headless Hook
useThunderPhone React Hook으로 완전히 맞춤화된 음성 UI를 구축합니다
useThunderPhone 훅은 ThunderPhone이 음성 세션, 오디오 라우팅 및 연결 상태를 관리하는 동안 사용자 인터페이스를 완전히 제어할 수 있게 해줍니다. ThunderPhone이 내부적으로 모든 작업을 처리하면서 자체 버튼, 레이아웃, 애니메이션, 브랜딩을 포함한 완전 맞춤형 UI가 필요할 때 사용합니다.
헤드리스 훅을 사용하는 경우
사전 구축된 ThunderPhoneWidget 컴포넌트는 대부분의 사용 사례를 지원하지만, 다음이 필요할 때는 헤드리스 훅을 사용합니다.
- 앱의 디자인 시스템에 맞는 완전 맞춤형 통화 UI
- 실시간 오디오 레벨로 구동되는 오디오 반응형 시각화(파형, 오브, 펄스 표시기)
- 통화 전 양식, 통화 후 설문조사, 음성과 함께 사용하는 인라인 채팅과 같은 맞춤형 통화 흐름
- 기존 컴포넌트 라이브러리(Material UI, Chakra, Radix 등)와의 통합
설치
npm install @thunderphone/widget기본 사용법
import { useThunderPhone } from '@thunderphone/widget'
function CustomCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const handleClick = () => {
if (phone.state === 'connected') {
phone.disconnect()
} else {
phone.connect()
}
}
return (
<>
<button onClick={handleClick} disabled={phone.state === 'connecting'}>
{phone.state === 'connecting'
? 'Connecting...'
: phone.state === 'connected'
? 'End call'
: 'Start call'}
</button>
{phone.audio}
</>
)
}옵션
UseThunderPhoneOptions를 통해 다음 옵션을 useThunderPhone에 전달합니다.
| 옵션 | 유형 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
publishableKey | string | 예 | -- | 공개 API 키(pk_live_...)입니다. 에이전트는 키의 위젯 구성에서 자동으로 확인됩니다. |
apiBase | string | 아니요 | 'https://api.thunderphone.com/v1' | API 기본 URL 재정의입니다. |
language | string | 아니요 | -- | 세션별 언어 재정의입니다 -- en, es, fr-FR과 같은 언어 코드 또는 로캘입니다. 설정하지 않으면 에이전트에 구성된 언어가 적용됩니다. |
voice | string | 아니요 | -- | 세션별 음성 재정의입니다 -- maria와 같은 음성 이름입니다. 설정하지 않으면 에이전트에 구성된 음성이 적용됩니다. |
context | string | 아니요 | -- | 에이전트에 전달되는 세션별 사실 기반 페이지 또는 사이트 컨텍스트입니다. 서버 측에서 12,000자로 잘립니다. |
onConnect | () => void | 아니요 | -- | 음성 세션이 연결될 때 호출됩니다. |
onDisconnect | () => void | 아니요 | -- | 세션이 종료될 때 호출됩니다. |
onError | (error) => void | 아니요 | -- | 오류 발생 시 호출됩니다. Error에는 error(코드) 및 message 필드가 있습니다. |
ringtone | boolean | string | 아니요 | false | 연결 중 벨소리를 재생합니다. 기본 벨소리는 true를 사용하고, 맞춤형 오디오는 URL 문자열을 사용합니다. |
반환 값
이 훅은 UseThunderPhoneReturn 객체를 반환합니다.
| 속성 | 유형 | 설명 |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | 현재 연결 상태입니다. |
connect | () => void | 음성 세션을 시작합니다. |
disconnect | () => void | 현재 세션을 종료합니다. |
toggleMute | () => void | 마이크 음소거를 켜거나 끕니다. |
isMuted | boolean | 마이크가 현재 음소거 상태인지 여부입니다. |
error | string | undefined | 상태가 'error'일 때의 오류 메시지입니다. |
agentName | string | undefined | 연결된 에이전트의 표시 이름입니다. |
audioLevel | number | 더 이상 사용되지 않음 -- 항상 0입니다. 이전 버전과의 호환성을 위해 유지되는 정적 자리 표시자이며, 업데이트되지 않습니다. 대신 audioLevelRef.current를 읽으세요. |
audioLevelRef | React.RefObject<number> | 에이전트 음성과 방문자 마이크 중 더 큰 실시간 오디오 레벨(0--1)을 포함하는 변경 가능한 ref입니다. React 렌더링 주기 외부에서 모든 애니메이션 프레임마다 업데이트됩니다. 부드럽고 끊김 없는 애니메이션을 위해 requestAnimationFrame 루프 내부에서 audioLevelRef.current를 읽거나, React 상태에서 값이 필요할 때는 일정 간격으로 샘플링하세요. |
audio | ReactNode | 오디오 연결을 처리하는 보이지 않는 요소입니다 -- 반드시 렌더링해야 합니다. |
오디오 반응형 UI
audioLevelRef ref는 React 리렌더링을 유발하지 않고 프레임 레이트 수준의 오디오 레벨을 제공하므로, 부드러운 파형 시각화, 맥동하는 오브 또는 대화에 연동된 모든 애니메이션을 구동하는 데 적합합니다. 이 레벨은 에이전트 음성과 방문자 마이크 중 더 큰 쪽을 반영합니다.
파형 예시
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function WaveformCall() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')!
let animId: number
const draw = () => {
const level = phone.audioLevelRef.current ?? 0
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw bars that react to audio level
const barCount = 24
const barWidth = canvas.width / barCount
for (let i = 0; i < barCount; i++) {
const distance = Math.abs(i - barCount / 2) / (barCount / 2)
const height = level * canvas.height * (1 - distance * 0.6)
const y = (canvas.height - height) / 2
ctx.fillStyle = '#0ea5e9'
ctx.fillRect(i * barWidth + 1, y, barWidth - 2, height)
}
animId = requestAnimationFrame(draw)
}
animId = requestAnimationFrame(draw)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<canvas ref={canvasRef} width={240} height={80} />
)}
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}맥동하는 오브 예시
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function PulsingOrb() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const orbRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
let animId: number
const animate = () => {
const level = phone.audioLevelRef.current ?? 0
if (orbRef.current) {
const scale = 1 + level * 0.5
orbRef.current.style.transform = `scale(${scale})`
orbRef.current.style.opacity = `${0.6 + level * 0.4}`
}
animId = requestAnimationFrame(animate)
}
animId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div style={{ textAlign: 'center' }}>
<div
ref={orbRef}
style={{
width: 80,
height: 80,
borderRadius: '50%',
background: '#0ea5e9',
margin: '20px auto',
transition: 'transform 0.05s ease-out',
}}
/>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Call'}
</button>
{phone.audio}
</div>
)
}발화 표시기 예시
볼륨에 따라 변경되는 React 렌더링 UI(예: 임곗값 기반 "발화 중" 배지)의 경우, 일정한 간격으로 audioLevelRef.current를 샘플링하고 결과를 상태에 저장합니다.
import { useEffect, useState } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function SpeakingBadge() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const [speaking, setSpeaking] = useState(false)
useEffect(() => {
if (phone.state !== 'connected') {
setSpeaking(false)
return
}
const interval = setInterval(() => {
setSpeaking((phone.audioLevelRef.current ?? 0) > 0.1)
}, 100)
return () => clearInterval(interval)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<span>{speaking ? 'Speaking' : 'Listening'}</span>
)}
<button onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}상태 머신
state 속성은 다음 수명 주기를 따릅니다.
idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
\
--> error (stays until connect() is called again)
| 상태 | 설명 |
|---|---|
idle | 활성 세션이 없습니다. connect()를 호출할 준비가 되었습니다. |
connecting | 세션을 설정하는 중입니다. 이 상태에서는 통화 버튼을 비활성화합니다. |
connected | 음성 세션이 활성 상태입니다. 사용자가 에이전트와 대화 중입니다. |
disconnected | 세션이 정상적으로 종료되었습니다. 1.5초 후 자동으로 idle 상태로 전환됩니다. |
error | 문제가 발생했습니다. 메시지는 phone.error에서 확인합니다. 이 상태는 자동으로 해제되지 않습니다. connect()를 다시 호출하면 새 시도가 시작되고 오류가 재설정됩니다. |
예시
음소거 제어 사용
import { useThunderPhone } from '@thunderphone/widget'
function CallWithMute() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div>
{phone.state === 'connected' && (
<div>
<p>Talking to {phone.agentName ?? 'Agent'}</p>
<button onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button onClick={phone.disconnect}>End call</button>
</div>
)}
{phone.state !== 'connected' && (
<button
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting' ? 'Connecting...' : 'Call support'}
</button>
)}
{phone.state === 'error' && (
<p style={{ color: 'red' }}>{phone.error}</p>
)}
{phone.audio}
</div>
)
}벨소리 사용
연결 중에 벨소리를 재생하여 전화 통화를 시뮬레이션합니다.
import { useThunderPhone } from '@thunderphone/widget'
function PhoneCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
ringtone: true, // or a custom URL: 'https://example.com/ringtone.mp3'
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting'
? 'Ringing...'
: phone.state === 'connected'
? 'Hang up'
: 'Call'}
</button>
{phone.audio}
</>
)
}벨소리는 connecting 상태 동안 반복 재생되며 에이전트가 연결되면 점차 사라집니다. 기본 내장 벨소리를 사용하려면 true를 전달하고, 자체 오디오 파일을 사용하려면 URL 문자열을 전달합니다.
이벤트 콜백 사용
import { useThunderPhone } from '@thunderphone/widget'
function TrackedCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
onConnect: () => {
analytics.track('call_started')
},
onDisconnect: () => {
analytics.track('call_ended')
},
onError: (error) => {
analytics.track('call_error', { code: error.error, message: error.message })
},
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'Hang up' : 'Talk to AI'}
</button>
{phone.audio}
</>
)
}완전한 맞춤 UI
import { useThunderPhone } from '@thunderphone/widget'
function FullCustomUI() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div className="call-panel">
<div className="call-status">
{phone.state === 'idle' && <span>Ready</span>}
{phone.state === 'connecting' && <span className="pulse">Connecting...</span>}
{phone.state === 'connected' && (
<span>On call with {phone.agentName}</span>
)}
{phone.state === 'error' && <span className="error">{phone.error}</span>}
</div>
<div className="call-controls">
{phone.state === 'connected' ? (
<>
<button className="mute-btn" onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button className="end-btn" onClick={phone.disconnect}>
End
</button>
</>
) : (
<button
className="start-btn"
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
Start call
</button>
)}
</div>
{/* Required -- handles audio under the hood */}
{phone.audio}
</div>
)
}팁
phone.audio를 항상 렌더링합니다
phone.audio 요소는 보이지 않지만 필수입니다. JSX의 아무 위치에나 배치합니다. 표시되는 DOM은 렌더링하지 않지만 내부적으로 WebRTC 오디오 연결을 관리합니다.
연결하는 동안 버튼을 비활성화합니다
connecting 상태는 1~3초 동안 지속될 수 있습니다. 중복 연결 시도를 방지하려면 이 상태 동안 통화 버튼을 비활성화합니다.
오류 상태를 적절하게 처리합니다
상태가 error이면 사용자에게 phone.error를 표시하고 통화 버튼은 활성화된 상태로 유지합니다. 이 훅은 자체적으로 error 상태를 벗어나지 않습니다. connect()를 다시 호출하면 새로운 시도가 시작되고 이전 오류가 지워집니다.
부수 효과에는 콜백을 사용합니다
onConnect, onDisconnect, onError 콜백은 상태를 폴링하지 않고 분석, 로깅 또는 다른 애플리케이션 로직을 트리거하는 데 적합합니다.
audioLevelRef에서 오디오 레벨을 읽습니다
audioLevelRef는 유일한 실시간 오디오 레벨 소스입니다. 파형과 같은 부드러운 애니메이션에는 requestAnimationFrame 내부에서 audioLevelRef.current를 읽습니다(ref를 읽어도 다시 렌더링되지 않음). 또는 일정한 간격으로 값을 샘플링하고 결과를 상태에 저장하여 React로 렌더링되는 UI에 사용할 수 있습니다. audioLevel 숫자는 더 이상 사용되지 않으며 항상 0입니다. 이를 기반으로 로직을 구축하지 마십시오.