add-read-aloud について
add-read-aloud は、既存アプリに Grok のテキスト読み上げを追加するスキルです。チャットやコンテンツアプリの開発者向けに、API キーをサーバー側に保ちながら、オンデマンド再生、自動再生、ストリーミング再生を扱います。
主な機能
- 01返信に読み上げボタンを追加
- 02バッチ合成とストリーミング合成
- 03Markdown を音声用に整形
- 04再生状態とエラーの管理
- 05音声、言語、速度の設定
活用シーン
- 01チャット返信の音声再生を追加
- 02長いページを音声で読み上げ
- 03テキストの生成中に音声を再生
- 04字幕やカラオケ用のタイムスタンプ生成
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ cursor/ plugins --skill add-read-aloudインストール後、Claude Code で/add-read-aloudを入力して読み込まれたか確認します。
詳細
読み上げを追加する
既存のアプリにGrokのテキスト読み上げを追加します。アシスタントの返信にスピーカーボタンを設置したり、自動読み上げを有効にしたり、任意のテキストをナレーションしたりできます。/add-read-aloud を実行するか、Read aloud と入力するか、「これを読み上げて」/「TTS」という明確な意図を伝えてください。Cursorにはスピーカー機能がありません。IDEではなく、アプリに組み込んでください。
ドキュメント
- https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
- APIリファレンス:https://docs.x.ai/developers/rest-api-reference/inference/voice
- カスタム音声:https://docs.x.ai/developers/model-capabilities/audio/custom-voices
- 料金(ドキュメントのみを参照):https://docs.x.ai/developers/pricing
方法を選択する
| 必要な機能 | 方法 |
|---|---|
| スピーカーをタップして、完成した返信を聞く。ページをナレーションする。ファイルを生成する。 | バッチ POST https://api.x.ai/v1/tts(デフォルト) |
| LLMのストリーミング中に音声を再生する。割り込み発話に対応する。15,000文字を超えるテキストを扱う。 | バックエンドのリレー経由でストリーミング wss://api.x.ai/v1/tts |
読み上げボタンには、バッチ処理がデフォルトです。リクエスト1回でMP3が1つ生成され、キャッシュ可能で、キーがサーバーの外に出ることはありません。テキストの完成前に音声が必要なUXの場合のみ、ストリーミングを使用してください。POST /v1/tts にストリーミング用のフラグはドキュメント化されていません。存在しないフラグを作らないでください。
認証
XAI_API_KEYをサーバー側でのみBearer認証に使用します。TTSのドキュメントには一時トークンのフローが記載されておらず、ブラウザーからWebSocketヘッダーを設定できないため、ブラウザーでのストリーミングはバックエンドのリレー経由で行います。- キーをクライアントバンドルに含めないでください。チャットにキーを貼り付けないでください。
手順
-
アプリを把握する
- アシスタントのメッセージが表示される場所、メッセージごとのアクション(コピー、再生成)、返信のストリームが終了するタイミング、サーバーフレームワーク、パッケージマネージャーを確認します。
- スピーカーアイコンは読み上げ用です。波形は音声モード(
/add-voice)、マイクはディクテーション(/add-dictation)用です。メッセージのアクション行にゴーストスピーカーボタンを配置します。読み込み中はスピナー、再生中は停止用の四角形を表示します。再生は常に1つだけにします。新しい再生を始めたら、現在の再生を停止します。 - アクション行はボタンの枠ではなく、返信のテキストの端に揃えます。アイコンボタンではグリフが中央に配置されるため、アシスタントの吹き出しにパディングがない場合は、その分だけ行を左に寄せます(たとえば、24 pxのボタンに12~14 pxのアイコンを使う場合は
-ml-[5px])。ブラウザーで計測し、<p>と<svg>のgetBoundingClientRectの左端が一致することを確認します。 - 返信のストリーミングが完了してからボタンを表示します。ストリーミング中に表示すると、返信の一部しか読み上げられません。
- 多数のメッセージが画面に表示される場合は、再生状態(アクティブなメッセージID、
loading | playing、直近のエラー)を共有ストア(useSyncExternalStore、シグナルなど、アプリで使われているもの)で一元管理し、すべてのボタンに反映させます。エラーはアプリ既存のステータス領域に表示できるようにします。ボタンごとにlet currentを持たせるだけでは不十分です。 - 自動読み上げ:オプトインの切り替えで有効にし、デフォルトではオフにします。また、ページ上でユーザーが操作した後に限ります(自動再生ポリシーへの対応)。ページの読み込み時に自動で読み上げてはいけません。
/add-voiceがインストールされている場合は、そのAudioContextとPCMプレーヤーでストリーミングTTSを再生できます。オーディオグラフを別途追加しないでください。
-
テキストを準備する
- マークアップではなく、文章を読み上げます。Markdownを取り除きます。見出しはテキストに、
**太字**はテキストに、リンクはリンクテキストに、インラインコードはコード自体に変換します。フェンス付きコードブロックは[pause] コードブロックを省略しました。に置き換えます。表は行ごとに1文にするか、省略します。句読点はテンポに影響するため、そのまま残します。 - 返信内に含まれる音声タグ(
[laugh]、<whisper>など)を無効化し、モデルがテキストによって読み上げ方を誘導できないようにします。あらゆる角括弧内の文字列を削除するのではなく、ドキュメントに記載されたタグ名(手順5の一覧)だけを取り除きます。[1]のような引用や[note]は残してください。 - バッチ処理の1リクエストあたりの上限は15,000文字です。これを超えるテキストは、段落、文、単語の順に区切って、分割した部分を順番に再生します。各部分の境目で無音時間が生じないように、Nの再生中にN+1を取得してください。またはストリーミングを使用します。
hash(text + voice_id + language + speed)をキーにキャッシュします。同じ返信が再生されることはよくあります。
- マークアップではなく、文章を読み上げます。Markdownを取り除きます。見出しはテキストに、
-
バッチ処理(デフォルト)
- サーバー:ルートで
{ text, voice_id?, language? }を受け取り、各フィールドの形式を検証します(voice_idは^[a-z0-9-]{1,64}$、languageは BCP-47 またはauto)。JSON を転送し、上流のContent-TypeとCache-Control: no-storeを付けてボディをストリーミングで返します。上流の 404 は「不明な音声」にマッピングし、クライアントに読みやすいエラーを返します。デフォルトの出力は 24 kHz/128 kbps の MP3 で、あらゆるブラウザーで再生できます。 language:ユーザーの言語に応じて応答するチャットアプリでは、デフォルトを"auto"にします。固定言語の製品でのみ"en"などを指定します。
- サーバー:ルートで
// server (any runtime with fetch)
export async function speak(text: string, voice_id = "eve", language = "auto") {
if (!text.trim() || text.length > 15_000) throw new Error("TTS text must be 1–15,000 chars");
const res = await fetch("https://api.x.ai/v1/tts", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
text,
voice_id,
language, // required: `auto` or BCP-47 (`en`, `pt-BR`); omitting it → 422
// output_format: { codec: "mp3", sample_rate: 24000, bit_rate: 128000 }, // default
// speed: 1.0, // 0.7–1.5
// text_normalization: true, // "$5" → "five dollars"
// replace: { nginx: "/ˈɛndʒɪn ˈɛks/" },
}),
});
if (!res.ok) throw new Error(`TTS ${res.status}`); // 400 bad text/format, 401 key, 404 unknown voice_id, 422 missing required field (e.g. language), 429/500/503 back off and retry
return new Response(res.body, { headers: { "Content-Type": res.headers.get("content-type") ?? "audio/mpeg" } });
}
// client
let current: HTMLAudioElement | null = null;
async function readAloud(text: string, voiceId = "eve") {
current?.pause(); current = null; // one utterance at a time
const res = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, voice_id: voiceId }) });
if (!res.ok) throw new Error("TTS request failed");
const url = URL.createObjectURL(await res.blob());
const audio = new Audio(url);
audio.addEventListener("ended", () => URL.revokeObjectURL(url)); // avoid blob leaks
current = audio;
await audio.play(); // call from the click handler’s promise chain
}
function stop() { current?.pause(); current = null; }
- Safari:blob URL では
audio.durationがInfinityになります。プログレスバーが必要な場合は、AudioContext.decodeAudioData(buf.slice(0))でデコードするか、with_timestamps: trueを指定して JSON エンベロープからdurationを読み取ります(この場合、音声はaudio内で base64 になります)。 - Safari では、ジェスチャーの外で作成した
AudioContextは停止状態のままになります。クリックハンドラー内で、awaitより前に同期的に作成してください。
- ストリーミング処理
- リレー:サーバーがキーを保持し、ブラウザーのソケットをアップグレードして、クエリ文字列を組み立て、クライアントからの JSON を上流へ、サーバーからの JSON を下流へ転送します。ブラウザー向けには
codec=pcmを指定します。生の PCM16 チャンクは到着順にスケジュールできますが、MP3 チャンクはMediaSourceなしでは断片ごとにデコードできません。
- リレー:サーバーがキーを保持し、ブラウザーのソケットをアップグレードして、クエリ文字列を組み立て、クライアントからの JSON を上流へ、サーバーからの JSON を下流へ転送します。ブラウザー向けには
import { WebSocketServer, WebSocket } from "ws";
new WebSocketServer({ port: 8789 }).on("connection", (client) => {
const q = new URLSearchParams({ language: "en", voice: "eve", codec: "pcm", sample_rate: "24000" /* optimize_streaming_latency: "1" */ });
const up = new WebSocket(`wss://api.x.ai/v1/tts?${q}`, { headers: { Authorization: `Bearer ${process.env.XAI_API_KEY}` } });
up.on("message", (d) => client.send(d.toString())); // audio.delta, audio.done, audio.clear, session.updated, error
client.on("message", (d) => up.readyState === WebSocket.OPEN && up.send(d.toString())); // text.delta, text.done, text.clear, session.update
const end = () => { client.close(); up.close(); };
up.on("close", end); up.on("error", end); client.on("close", end);
});
- クライアント:チャットセッションごとにソケットを 1 つ使用します(発話間も開いたままです。チームあたり 50 件の同時セッション、許可の TTL は 600 s なので、接続が閉じたら再接続します)。LLM のトークンを
text.deltaとして転送します(それぞれ 15,000 文字以下)。応答が完了したらtext.doneを送信し、停止時または新しい応答の開始時にはtext.clearを送信します。audio.clearの際は、キューに入った音声を破棄します。
const ws = new WebSocket(relayUrl);
const ctx = new AudioContext({ sampleRate: 24000 }); // create in the click handler; resume if suspended
let playhead = 0, sources: AudioBufferSourceNode[] = [];
ws.addEventListener("message", (e) => {
const ev = JSON.parse(e.data);
if (ev.type === "audio.delta") {
const bytes = Uint8Array.from(atob(ev.delta), (c) => c.charCodeAt(0));
const pcm = new Int16Array(bytes.buffer, 0, bytes.byteLength >> 1);
const buf = ctx.createBuffer(1, pcm.length, 24000);
const ch = buf.getChannelData(0);
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination);
playhead = Math.max(playhead, ctx.currentTime + 0.15); // ~150 ms lead so chunks butt together
src.start(playhead); playhead += buf.duration; sources.push(src);
} else if (ev.type === "audio.done") { /* utterance finished; socket stays open */ }
else if (ev.type === "audio.clear") { sources.forEach((s) => s.stop()); sources = []; playhead = 0; }
else if (ev.type === "error") showError(ev.message);
});
// on each LLM token: ws.send(JSON.stringify({ type: "text.delta", delta: token }))
// on reply finished: ws.send(JSON.stringify({ type: "text.done" }))
// on stop / barge-in: ws.send(JSON.stringify({ type: "text.clear" })) → wait for audio.clear before the next text.delta
text.deltaの境界で単語が分割されても問題ありません。照合と音声合成はデルタをまたいで実行されます。
- オプション(バッチ処理では JSON フィールド、ストリーミングではクエリパラメーター)
| 目的 | 設定 |
|---|---|
| 別の音声を使う | voice_id(バッチ処理)/voice(ストリーミング)。組み込み音声は GET /v1/tts/voices で取得できます:eve(デフォルト)、ara、rex、leo、luna、atlas、aurora、orion、…全 28 種類で、すべて多言語対応、英字の大文字・小文字を区別しません。カスタム音声:コンソールまたは GET /v1/custom-voices で取得する 8 文字の ID |
| 英語以外または複数言語 | language:en、ar-EG、ar-SA、ar-AE、bn、zh、fr、de、hi、id、it、ja、ko、pt-BR、pt-PT、ru、es-MX、es-ES、tr、vi、または auto |
| 話速を上げる/下げる | speed 0.7–1.5 |
| 「$5」「Dr.」「3/4」を単語として読み上げる | text_normalization: true |
| ブランド名、頭字語、専門用語 | replace: { "Acme Mobile": "Acme Mobull", "nginx": "/ˈɛndʒɪn ˈɛks/" }:最大 200 件、キーは最大 100 文字(英字、数字、アポストロフィ、スペース)、値は最大 128 文字。単語全体を対象に大文字・小文字を区別せず照合し、最長一致を優先します。ストリーミングでは最初の text.delta の前に session.update { replace } を送信します |
| 表現豊かな読み上げ | インラインで [pause]、[long-pause]、[laugh]、[chuckle]、[giggle]、[cry]、[sigh]、[breath]、[inhale]、[exhale]、[tsk]、[tongue-click]、[lip-smack]、[hum-tune] を指定します。フレーズ全体を <whisper>、<soft>、<loud>、<emphasis>、<build-intensity>、<decrease-intensity>、<slow>、<fast>、<higher-pitch>、<lower-pitch>、<singing>、<sing-song> で囲みます |
| 字幕、カラオケ、リップシンク | with_timestamps: true → JSON { audio (base64), content_type, duration, audio_timestamps: { graph_chars[], graph_times[][start,end] } }。graph_chars の順に処理し、インデックスで入力を分割しないでください |
| 最初の音声を早く取得する(ストリーミング) | optimize_streaming_latency=1(ドキュメントには 2 も記載されています。API リファレンスには 0/1 が記載されています) |
| 電話/IVR | output_format: { codec: "mulaw" | "alaw", sample_rate: 8000 }。ブラウザーでは再生できません |
| 編集、ポストプロダクション | codec: "wav"、sample_rate: 44100 または 48000 |
| ファイルサイズを小さくする | codec: "mp3"、bit_rate: 64000 |
- Python 版(サーバーが Python の場合のみ)
import os, requests
r = requests.post(
"https://api.x.ai/v1/tts",
headers={"Authorization": f"Bearer {os.environ['XAI_API_KEY']}"},
json={"text": text, "voice_id": "eve", "language": "en"},
)
r.raise_for_status(); audio_bytes = r.content # audio/mpeg
# streaming: websockets.connect(url, additional_headers={"Authorization": f"Bearer {key}"}); send {"type":"text.delta",...}, {"type":"text.done"}; read audio.delta / audio.done
- 動作確認
- バッチ:
curl -X POST https://api.x.ai/v1/tts -H "Authorization: Bearer $XAI_API_KEY" -H "Content-Type: application/json" -d '{"text":"Hello from read aloud.","voice_id":"eve","language":"en"}' --output /tmp/hello.mp3→ 200audio/mpeg(MP3、24 kHz、128 kbps、モノラル)で再生できます。languageを省略すると 422 になります(確認済み。ドキュメントの表には 400 のみ記載されています)。voice_id: "nope"→ 404。 - アプリ内:返信のストリーミング中はスピーカーが表示されず、返信の終了時に表示されます。クリック → スピナー → 停止アイコン → 音声終了後にスピーカーに戻ります。再生中にもう一度クリックするとすぐに停止し、最初の返信の再生中に2つ目の返信をクリックすると、最初の再生が停止して2つ目が再生されます。Safari:最初の再生はクリック操作から正常に動作し、
URL.revokeObjectURLはended時に実行されます。 - テキストの前処理:コードフェンス、表、
[1]の引用、不要な[laugh]を含む返信 → 「コードブロックは省略」と読み上げられ、各行は1文ずつ、引用はそのまま残り、タグは削除されます。これをユニットテストしてください。これは純粋な処理です。 - ストリーミング:2文の返信をトークンごとに送信します。音声は
text.doneより前に再生を開始する必要があります。発話の途中でtext.clearを送信すると、audio.clearが送られ、古いデータを残さずに再生が停止します。同じソケットで2つ目の発話を行うと、新しいaudio.deltaが送られ、最初の発話の音声が混ざりません。 - クライアントバンドルで
XAI_API_KEYを検索してください。含まれていてはいけません。稼働中の開発サーバーに対して/を取得し、参照されている/_next/static/chunks/*.js(または同等の)URLを収集して、それぞれを grep してください。作成していない本番ビルドに頼ってはいけません。 - ログを使って
/debug-voiceからデバッグし、フックの接続先をaudio.delta(バイト数)、audio.done、audio.clear、errorに切り替えてください。
- バッチ:
対象外
- 音声を聞いて応答する音声エージェント(
/add-voice)、音声からテキストへの変換(/add-dictation) - 既存のカスタム
voice_idを渡す以外の音声クローン - TTS トークンフロー、
POST /v1/ttsのstreamフラグ、またはドキュメントに記載されていないイベント名の考案
コメント
Markdown 対応