---
title: "Headless Hook"
description: "useThunderPhone Reactフックで完全にカスタム可能な音声UIを構築"
---

`useThunderPhone` フックを使用すると、ThunderPhone が音声セッション、音声ルーティング、接続状態を管理しながら、ユーザーインターフェースを完全に制御できます。独自のボタン、レイアウト、アニメーション、ブランディングを備えた完全にカスタムな UI が必要で、ThunderPhone にバックグラウンドの処理を任せたい場合に使用します。

## ヘッドレスフックを使用する場合

ビルド済みの `ThunderPhoneWidget` コンポーネントはほとんどのユースケースに対応していますが、次のような場合はヘッドレスフックを使用してください。

- アプリのデザインシステムに一致する完全にカスタムな通話 UI
- リアルタイムの音声レベルに基づいて動作する音声反応型ビジュアライゼーション（波形、オーブ、パルスインジケーター）
- 通話前フォーム、通話後アンケート、音声と併用するインラインチャットなどのカスタム通話フロー
- 既存のコンポーネントライブラリ（Material UI、Chakra、Radix など）への統合

---

## インストール

```bash
npm install @thunderphone/widget
```

<Note>
  独自の UI を提供するため、ヘッドレスフックでは `@thunderphone/widget/style.css` をインポートする必要は**ありません**。ただし、同じ `@thunderphone/widget` パッケージをインストールする必要があります。
</Note>

---

## 基本的な使用方法

```tsx
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}
    </>
  )
}
```

<Warning>
  **コンポーネントツリー内のどこかで `phone.audio` をレンダリングする必要があります。** これは基盤となる音声接続を管理する不可視の React 要素です。これを省略すると音声は再生されず、セッションは機能しません。
</Warning>

---

## オプション

`UseThunderPhoneOptions` を介して、これらのオプションを `useThunderPhone` に渡します。

| オプション | 型 | 必須 | デフォルト | 説明 |
|--------|------|----------|---------|-------------|
| `publishableKey` | `string` | はい | -- | 公開可能な API キー（`pk_live_...`）。エージェントはキーのウィジェット設定から自動的に解決されます。 |
| `apiBase` | `string` | いいえ | `'https://api.thunderphone.com/v1'` | API ベース URL のオーバーライド。 |
| `language` | `string` | いいえ | -- | セッション単位の言語オーバーライド。`en`、`es`、`fr-FR` などの言語コードまたはロケールを指定します。未設定の場合は、エージェントに設定された言語が適用されます。キーの [ブラウザーオーバーライド](/ja/guides/embed-web-widget) で `language` を指定する必要があります。 |
| `voice` | `string` | いいえ | -- | セッション単位の音声オーバーライド。`maria` などの音声名を指定します。未設定の場合は、エージェントに設定された音声が適用されます。キーの [ブラウザーオーバーライド](/ja/guides/embed-web-widget) で `voice` を指定する必要があります。 |
| `context` | `string` | いいえ | -- | エージェントに渡すセッション単位の事実に基づくページまたはサイトのコンテキスト。サーバー側で 12,000 文字に切り詰められます。キーの [ブラウザーオーバーライド](/ja/guides/embed-web-widget) で `context` を指定する必要があります。 |
| `onConnect` | `() => void` | いいえ | -- | 音声セッションの接続時に呼び出されます。 |
| `onDisconnect` | `() => void` | いいえ | -- | セッションの終了時に呼び出されます。 |
| `onError` | `(error) => void` | いいえ | -- | エラー発生時に呼び出されます。エラーには `error`（コード）フィールドと `message` フィールドがあります。 |
| `ringtone` | `boolean \| string` | いいえ | `false` | 接続中に着信音を再生します。デフォルトの着信音には `true`、カスタム音声には URL 文字列を指定します。 |

<Note>
  このフックはヘッドレスです。`ThunderPhoneWidget` の外観プロパティ（`theme`、`primaryColor`、`title`、`position`、`className`）は受け取り**ません**。これらを渡すと TypeScript エラーになります。表示部分はすべて独自に実装します。
</Note>

---

## 戻り値

このフックは `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 state で値が必要な場合は一定間隔でサンプリングしてください。 |
| `audio` | `ReactNode` | 音声接続を処理する非表示要素です -- **必ずレンダリングする必要があります**。 |

---

## 音声反応型UI

`audioLevelRef` ref は、React の再レンダリングを発生させずにフレームレートで音声レベルを取得できるため、滑らかな波形表示、脈動するオーブ、または会話に連動するあらゆるアニメーションの制御に最適です。このレベルには、エージェントの音声と訪問者のマイクのうち、より大きい方が反映されます。

### 波形の例

```tsx
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>
  )
}
```

### 脈動するオーブの例

```tsx
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` を一定間隔でサンプリングし、結果を state に保存します。

```tsx
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>
  )
}
```

<Warning>
  音声レベルは必ず `audioLevelRef.current` から読み取ってください。返り値オブジェクトの `audioLevel` 数値は **非推奨であり、常に `0`** です。これに基づくロジックは、暗黙的にゼロを読み取ります。
</Warning>

---

## 状態マシン

`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()` を再度呼び出すと、新しい試行が開始され、エラーがリセットされます。 |

---

## 例

### ミュート制御を使用

```tsx
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>
  )
}
```

### 着信音を使用

電話をシミュレートするため、接続中に着信音を再生します。

```tsx
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 文字列を渡します。

### イベントコールバックを使用

```tsx
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

```tsx
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>
  )
}
```

---

## ヒント

<AccordionGroup>
  <Accordion title="phone.audio を常にレンダリングする">
    `phone.audio` 要素は非表示ですが必須です。JSX 内の任意の場所に配置してください。表示される DOM はレンダリングされませんが、内部で WebRTC オーディオ接続を管理します。
  </Accordion>

  <Accordion title="接続中はボタンを無効にする">
    `connecting` 状態は 1〜3 秒続く場合があります。この状態の間は通話ボタンを無効にして、重複した接続試行を防いでください。
  </Accordion>

  <Accordion title="エラー状態を適切に処理する">
    状態が `error` の場合は、`phone.error` をユーザーに表示し、通話ボタンは有効なままにしてください。このフックは自動的に `error` 状態を終了しません。再度 `connect()` を呼び出すと、新しい試行が開始され、以前のエラーがクリアされます。
  </Accordion>

  <Accordion title="副作用にはコールバックを使用する">
    `onConnect`、`onDisconnect`、`onError` コールバックは、状態をポーリングせずに分析、ログ記録、または他のアプリケーションロジックのトリガーを行うのに最適です。
  </Accordion>

  <Accordion title="audioLevelRef からオーディオレベルを読み取る">
    `audioLevelRef` は唯一のライブオーディオレベルソースです。波形などの滑らかなアニメーションでは `requestAnimationFrame` 内で `audioLevelRef.current` を読み取ってください（ref の読み取りでは再レンダリングは発生しません）。または、一定間隔でサンプリングし、結果を state に保存して React でレンダリングされる UI に使用できます。`audioLevel` 数値は非推奨で、常に `0` です。この値に基づくロジックは構築しないでください。
  </Accordion>
</AccordionGroup>
