Rork で AI キャラクターアプリを作って TestFlight に出してから気付いたのですが、テキストチャットだけのコンパニオン系アプリは、最初の3日間でアクティブ率が半分以下に落ちます。これは私のアプリだけの問題ではなく、Sensor Tower が公開している同ジャンルアプリの定着率レポートでも同じ傾向が出ています。
決定的に空気が変わったのは、ElevenLabs の音声合成を組み込んでキャラクターが「声で返す」ようにしたタイミングでした。テキストの内容は同じでも、声がつくだけで滞在時間が 2.4 倍に伸び、サブスクリプションのトライアル開始率も上がりました。ここではRork で開発しているアプリに ElevenLabs を統合する具体的な実装手順と、コストを抑えながら継続的に運用するためのコツをまとめています。
なぜテキストだけのコンパニオンアプリは続かないのか
私が最初に作ったキャラクターアプリは、Claude API でテキストを生成し、画面下部に吹き出しで表示するシンプルな構成でした。リリース直後の感想で多かったのが「結局 ChatGPT と何が違うのか分からない」という声です。当然です。テキストチャットの体験は、すでに大手のチャットアプリで完成されています。
声を加えたあとに見えた変化は、数値以上に「使い方」の質的な変化でした。
- ベッドサイドに置いて、寝る前に話しかけるユーザーが増えた
- 朝の通勤中、画面を見ずにイヤホンで会話する利用が出てきた
- 「キャラクターが自分の名前を呼んでくれる」ことに対する満足度の高いレビューが付くようになった
つまり「声で返す」体験は、画面に拘束されない時間帯のユーザーを掴むための鍵になります。Rork で UI を素早く組めるのと、ElevenLabs で日本語の自然な発話が API 経由で取れるのを掛け合わせると、個人開発でもこのレベルの体験を1週間程度で組み上げられるようになりました。
ElevenLabs を選ぶ理由(OpenAI TTS / Google Cloud TTS との比較で迷ったとき)
音声合成 API はいくつか選択肢があります。私はすべて課金して試した上で、コンパニオンアプリ用途では ElevenLabs を推しています。比較する際の判断軸を共有しておきます。
- 日本語の自然さ: ElevenLabs の Multilingual v2 は、漢字交じりの長文でもイントネーションが自然です。OpenAI TTS は流暢ですがやや棒読み気味、Google Cloud TTS は WaveNet でも語尾の抑揚が薄く感じます
- キャラクター性: ElevenLabs は Voice Design 機能で、声色(明るい / 落ち着いた / 少し低め など)をプロンプトで指定して新規ボイスを生成できます。OpenAI は固定6声、Google は男女と年齢のバリエーションのみ
- レイテンシ: ElevenLabs Flash v2.5 は最初のオーディオチャンクが返るまで 75ms 前後(公式ベンチ)。OpenAI TTS は 300ms 前後、Google Cloud TTS は 200ms 前後で、リアルタイム会話では Flash v2.5 の差が体感できます
- コスト: ElevenLabs の文字単価はやや高め(Creator プラン $22 /月で 100k 文字、超過は $0.30 /1k 文字程度)。ただしキャッシュ戦略を組めば運用は十分現実的です
「キャラクターらしい個性的な声」「日本語の自然さ」を最優先するなら ElevenLabs、「とにかくコストを抑えたい」なら OpenAI TTS、という棲み分けが私の中での結論です。
アプリの全体構成
今回作るのは、以下のフローで動くシンプルなコンパニオンアプリです。
- ユーザーがテキストまたは音声で発話する
- Whisper(または OpenAI Realtime API)でテキスト化
- Claude / GPT に投げて応答テキストを生成
- 応答テキストを ElevenLabs API に渡して音声に変換
- アプリ側で再生&字幕表示
Rork 側は React Native / Expo ベースで、状態管理は Zustand、音声再生は expo-av を使います。バックエンドは Cloudflare Workers に置いて API キーをアプリに埋め込まない構成にしています。
ElevenLabs API キーの取得とセキュアな置き場所
まず ElevenLabs にサインアップし、ダッシュボードから API キーを発行します。注意点を3つ挙げます。
- アプリにキーを直接埋め込まない: バンドル解析で簡単に抜かれます。Cloudflare Workers などのバックエンドで中継してください
- Voice ID もシークレット扱い: カスタム Voice を Voice Cloning で作った場合、Voice ID もログに出さないこと。第三者がそのキャラクターの声で発話できてしまいます
- 使用量アラートを設定: ダッシュボードの Usage タブから、月間文字数の80%でメール通知が飛ぶように設定しておくと、暴走時のダメージを防げます
私は最初これを怠って、デバッグ中の無限ループでひと晩で1万円分の文字数を消費した経験があります。アラートは初日に必ず入れてください。
Cloudflare Workers でのプロキシ実装
Rork で開発するアプリから直接 ElevenLabs を叩かず、Cloudflare Workers をプロキシにします。Worker 側のコードは以下のような形になります。
// worker/src/index.ts — ElevenLabs プロキシ
// 機能: アプリから受け取ったテキストを ElevenLabs に渡し、音声バイナリを返す
// なぜ Worker 経由か: API キーをアプリに埋め込まないため + レート制限/キャッシュ層を持つため
export interface Env {
ELEVENLABS_API_KEY: string;
ELEVENLABS_VOICE_ID: string;
AUTH_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
// ① アプリからのリクエスト認証(Rork 側で発行する短期トークンを検証)
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${env.AUTH_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// ② リクエストボディからテキストとモデルを取得
const { text, modelId = "eleven_flash_v2_5" } = await request.json<{
text: string;
modelId?: string;
}>();
if (!text || text.length > 1000) {
return new Response("Invalid text length", { status: 400 });
}
// ③ ElevenLabs にリクエスト(音声バイナリをそのまま返却)
const elevenRes = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${env.ELEVENLABS_VOICE_ID}`,
{
method: "POST",
headers: {
"xi-api-key": env.ELEVENLABS_API_KEY,
"Content-Type": "application/json",
"Accept": "audio/mpeg",
},
body: JSON.stringify({
text,
model_id: modelId,
voice_settings: {
stability: 0.5, // 0.5 で表現の幅と安定性のバランス
similarity_boost: 0.75, // 元音声への忠実度
style: 0.3, // キャラクター性の強さ(高すぎるとノイズが出る)
use_speaker_boost: true,
},
}),
}
);
if (!elevenRes.ok) {
const errText = await elevenRes.text();
console.error("ElevenLabs error:", elevenRes.status, errText);
return new Response("TTS upstream error", { status: 502 });
}
// ④ 音声を MP3 として返す(クライアントで expo-av が再生)
return new Response(elevenRes.body, {
headers: {
"Content-Type": "audio/mpeg",
"Cache-Control": "public, max-age=86400",
},
});
},
};AUTH_SECRET は Rork アプリと共有する短期トークンです。本番では Sign in with Apple や Firebase Auth で発行する JWT を検証する形に拡張してください。期待する動作は、テキストを POST すると数百ミリ秒で MP3 のバイナリストリームが返ってくることです。
Rork アプリ側の音声再生フック
Rork で UI を組むとき、音声再生は再利用可能なカスタムフックにしておくと便利です。
// hooks/useVoiceResponse.ts — テキストを ElevenLabs 音声で再生するフック
// 解決すること: 重複再生防止、ローディング状態管理、エラー時の無音フォールバック
import { useState, useRef, useCallback } from "react";
import { Audio } from "expo-av";
import * as FileSystem from "expo-file-system";
const PROXY_URL = process.env.EXPO_PUBLIC_TTS_PROXY_URL!;
const AUTH_SECRET = process.env.EXPO_PUBLIC_TTS_AUTH_SECRET!;
export function useVoiceResponse() {
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const soundRef = useRef<Audio.Sound | null>(null);
const speak = useCallback(async (text: string) => {
if (!text.trim()) return;
// 再生中なら一度止める(被って再生されると不快なため)
if (soundRef.current) {
await soundRef.current.unloadAsync();
soundRef.current = null;
}
setIsLoading(true);
try {
const res = await fetch(PROXY_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${AUTH_SECRET}`,
},
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(`TTS HTTP ${res.status}`);
// ファイルとして保存(expo-av は URI から読み込む方が安定)
const blob = await res.blob();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve((reader.result as string).split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const fileUri = `${FileSystem.cacheDirectory}voice_${Date.now()}.mp3`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
const { sound } = await Audio.Sound.createAsync(
{ uri: fileUri },
{ shouldPlay: true }
);
soundRef.current = sound;
setIsPlaying(true);
sound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded && status.didJustFinish) {
setIsPlaying(false);
sound.unloadAsync();
}
});
} catch (err) {
console.error("TTS playback failed:", err);
// 失敗時はサイレントフォールバック(テキスト表示のみで継続)
} finally {
setIsLoading(false);
}
}, []);
const stop = useCallback(async () => {
if (soundRef.current) {
await soundRef.current.stopAsync();
await soundRef.current.unloadAsync();
soundRef.current = null;
setIsPlaying(false);
}
}, []);
return { speak, stop, isPlaying, isLoading };
}このフックを画面側で呼び出せば、Claude から返ってきた応答テキストをそのまま音声で再生できます。expo-av の Audio.Sound は使い終わったら必ず unloadAsync で解放しないと、メモリリークで2〜3分後にアプリがクラッシュする問題があります。私は実機テストで3度この壁にぶつかったので、忘れずに書いてください。
コストを抑えるためのキャッシュ戦略
ElevenLabs は文字単価で課金されるため、同じセリフを毎回 API に投げると課金がすぐに膨らみます。私は以下の3層キャッシュで運用コストを 70% 削減しました。
- クライアント側ローカルキャッシュ: ユーザーごとに最近聞いた100件をローカルファイルに保存。よく聞く挨拶(「おはよう」「ありがとう」など)はここでヒットさせる
- Cloudflare KV キャッシュ: テキストの SHA-256 ハッシュをキー、音声 URL を値として保存。全ユーザーで共有できる定型文(オンボーディング、エラーメッセージ)はここで打ち返す
- Cloudflare R2 で音声ファイル保存: KV にはメタデータのみ、音声バイナリ自体は R2 に置いて TTL 30日で運用
実装の順序としては、まず KV キャッシュだけ入れてから運用してみて、ヒット率を見ながらローカルキャッシュ・R2 を足していくのが安全です。最初から3層全部組むと、デバッグが大変になります。
ペイウォールとの組み合わせ方
音声機能は無料でも一定回数使えるようにし、月間の使用上限を超えたらサブスクリプションに誘導する構成が、私が試した中で最もコンバージョンが高かったパターンです。
- 無料: 1日10回まで音声再生
- Pro 月額: 無制限 + キャラクター切替(複数 Voice ID)
- Premium 永久: 無制限 + カスタム Voice 作成(ElevenLabs Voice Design 連携)
「無料でテキストは無制限・音声だけ制限」とすることで、コア体験は損なわずに自然な課金導線が作れます。Rork で組むときは Rork で RevenueCat を使ったサブスクリプション実装ガイド と組み合わせると、課金実装まで含めて1週間で動くものが作れます。
実装後に必ず確認すべき3つのこと
リリース前に必ずチェックしてほしいポイントを共有します。私はこれを怠って、リリース後に修正パッチを3回出すことになりました。
- iOS のサイレントモードでも音声が再生されるように設定する:
Audio.setAudioModeAsync({ playsInSilentModeIOS: true })を起動時に呼ばないと、サイレント時に無音になり「壊れている」というレビューが付きます - Bluetooth イヤホン接続時の音切れ対策: AirPods などでアプリをバックグラウンドにすると音が止まる場合があります。
staysActiveInBackground: trueを設定してください - 長文テキストの分割再生: ElevenLabs は1リクエスト最大1000文字程度が安定する上限です。Claude の応答が長い場合は、句点で分割して順次再生する仕組みを入れます
このあたりは詳しく書くと記事3本分になるので、別途 Rork で WebRTC を使ったビデオ通話アプリの完全実装ガイド や Rork × LiveKit 音声・動画エージェント本番運用ガイド も参考にしてみてください。
まずは「自分のキャラクター」を1つ作ってみる
ここまで読んでいただいた方には、次のアクションとして「自分のアプリ用に ElevenLabs Voice Design で1つだけキャラクターボイスを作って、Rork のサンプル画面で再生してみる」ところまで進めてみることをおすすめします。実際に音が鳴った瞬間、テキストチャットだけのアプリでは届かなかった層に手が届く感覚が掴めるはずです。
ElevenLabs の Free プランは月30,000文字まで使えるので、まずは無課金でキャラクター1体分の発話を仕込んで、自分のアプリのトップ画面に置いてみてください。声が出た瞬間に、UI に何を足すべきかが一気に見えてきます。