debug-voice について
音声セッションにログを追加し、その記録を根拠に問題を調査する開発スキルです。さまざまな技術スタックに対応し、編集前の計画合意と、テスト後に確認できるイベントの秘匿化記録を定めています。
主な機能
- 01編集前に計画の合意を必須化
- 02機密情報を秘匿して音声イベントを記録
- 03音声統計とセッションログの集約
- 04ログの特徴に基づく修正確認
活用シーン
- 01音声セッションの無音や切断の調査
- 02再生の途切れや初回発話遅延の分析
- 03実テストのログを使った修正検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cursor/ plugins --skill debug-voiceインストール後、Claude Code で/debug-voiceを入力して読み込まれたか確認します。
詳細
Debug Voice
音声セッションを後から読み返せるようにし、記録を根拠に修正します。エージェントにはアプリの音声が聞こえません。ログが耳となり、ユーザーが判断します。本番環境では音声もトークンも扱いません。
どのようなスタックでも使えます。パイプラインは小さな契約(以下参照)です。アプリですでに使っているものを使って実装してください。
ワークフロー
- 把握:アプリを読み取り専用で調査します。
- 計画:変更一覧を書き、提示して、そこで止まります。ユーザーの合意が得られるまで、何も編集しません。
- 合意した要素を、アプリの言語、フレームワーク、規約に沿って導入します。
- 確認:シンクを検証し、アプリをユーザーに渡します。
- ログを基に修正を繰り返します。
1. 把握
以下を見つけて、パスを記録します。
- 音声クライアント:リアルタイムイベントの受信・送信、マイクのキャプチャ、音声の再生、トークンの取得を行う場所。
- サーバー:フレームワーク、ルートの宣言方法、共通サーバーコードの場所、環境変数の読み取り方法、この環境で「本番」が意味するもの。
- 規約:言語、モジュールシステム、フォーマッター、スクリプトやタスクの配置場所(
package.json、Makefile、pyproject、justfile)、.gitignore。 - クライアントの種類:ブラウザー、モバイル、デスクトップ、CLI。ブラウザー以外のクライアントでも同じ JSON を POST します。単一プロセスのアプリでは HTTP を省略して、ファイルに直接追記できます。
- オーディオデルタを処理する場所。件数を数える必要がありますが、ログに記録してはいけません。
2. 計画して、そこで止まる
実際のパスとアプリの言語を使って以下を埋め、提示して、承認または簡略化したリストが返ってくるまで待ちます。それまではファイルを編集しないでください。
## Debug voice: plan
Add
- <path>: client logger (batch, redact, flush) in <language>
- <path>: dev-only sink `POST /api/voice/log` → `.voice-logs/<sessionId>.ndjson`
- <path> (optional): summary command `<cmd>`; otherwise read the NDJSON with jq
Modify
- <voice client file>: hook points start, token, mic, env, ws.*, client/server events,
audio.in (2 s windows), audio.out.first, audio.out, play.stop, stop
- <token route>: append `server.token { ok, status, ms, upstream }` (never the token)
- <UI file>: session id in the voice status line and in voice error messages
- .gitignore: `/.voice-logs`
- <scripts file>: a `voice:logs` task (only if the summary command is wanted)
Logged: event names and non-audio fields, timings, byte counts, mic RMS.
Never: tokens, API keys, raw audio, strings over 400 chars.
Off in production unless `VOICE_LOG=1`.
Reply "go", or strike lines you do not want.
3. 導入:契約
アプリに合わせます。周辺コードと同じ言語、同じルート形式、同じフォーマッターを使います。以下の契約に沿って各要素を作成してください。ログ記録のために別の言語やツールチェーンを導入しないでください。
セッション ID:UUID から得られる 8 文字の小文字の 16 進数。シンクは ^[a-z0-9]{4,64}$ を受け付けます。これがファイル名になります。
エントリー(1 行につき JSON オブジェクト 1 つ):
| フィールド | クライアント | サーバー |
|---|---|---|
t | ロガーの起動からの経過ミリ秒 | なし。読み取り側は ts で位置を合わせます |
ts | エポックミリ秒 | エポックミリ秒 |
kind | start、server、client、error、audio.in、… | server.token、… |
src | なし | "server" |
| 残り | フックのフィールド。秘匿化済み | フックのフィールド |
秘匿化。バッファーに入れる前にクライアント側で適用します:オーディオイベントの種類(response.output_audio.delta、response.audio.delta、input_audio_buffer.append)では、delta/audio を bytes に置き換えます。値はデコード後の base64 の長さとします。400 文字を超える文字列は、400 文字+…[N chars] に切り詰めます。4 階層を超えるオブジェクトは "[depth]" に置き換えます。50 項目を超える配列は切り詰めます。
クライアントロガー:エントリーをバッファーに保存し、1 秒ごと、または 200 エントリーごとに POST <sink> {"sessionId","entries":[…]} を送信します。停止時には keepalive(またはプラットフォームでページ遷移後も処理を継続させる同等の機能)を使ってフラッシュします。すべての通信エラーを握りつぶし、ログ記録が音声処理に例外を伝播させないようにします。開発環境ではエントリーをコンソールにも出力します。
シンク:POST /api/voice/log、JSON ボディ。開発環境または VOICE_LOG=1 以外では 404 を返します。sessionId が正規表現に一致しない場合、または entries が配列でない場合は 400 を返します。リクエストごとに最大 500 件のエントリーを .voice-logs/<sessionId>.ndjson に追記し、ディレクトリがなければ作成します。16,000 文字を超える行は破棄します。204 を返します。
任意のサーバーで使える疑似コード:
handle POST /api/voice/log:
if production and VOICE_LOG != "1": return 404
body = parse json or return 400
if not regex(body.sessionId) or not list(body.entries): return 400
mkdir .voice-logs; append join(json(e) for e in body.entries[:500] if len < 16000) to .voice-logs/{sessionId}.ndjson
return 204
クライアントロガーの疑似コード:
logger(sessionId, sink):
buffer = []; started = now()
log(kind, data): buffer.push({ ...redact(data), t: now() - started, ts: epoch_ms(), kind }); schedule flush (1 s timer, or immediately at 200 entries)
server(event, extra): log("server", { ...redact_event(event), ...extra }) # never per audio delta
client(event): log("client", redact_event(event)) # never per audio chunk
error(where, err, extra): log("error", { where, name, message, ...extra })
flush(final=false): POST sink {"sessionId","entries": buffer}; buffer = []; ignore all errors; keepalive when final
close(): flush(final=true)
フックポイント
kind とフィールドは、どの言語でも同じ形です。
| タイミング | kind とフィールド |
|---|---|
| セッション開始 | start { url, target_rate } |
| トークン取得/失敗 | token.ok { ms } / error { where: "token", name, message, ms } |
| マイク許可/拒否 | mic.ok { ms, label, settings } / error { where: "mic", … } |
| オーディオグラフ準備完了 | env { ua, mic_rate, mic_state, play_rate, play_state, capture_frames, target_rate } |
| WebSocket | ws.connecting、ws.open { ms }、ws.error、ws.close { code, reason, wasClean, by } |
| 音声チャンクを除く、送信したすべてのイベント | client { …編集済みイベント } |
| 音声デルタを除く、受信したすべてのイベント | server { …編集済みイベント, phase } |
| フェーズ変更(重複排除) | phase { phase } |
| 2 秒ごとに集計したマイクチャンク | audio.in { chunks, bytes, rms_max, rms_avg, pending, mic_state, phase } |
| 接続時に送信するオープン前バッファー | audio.flush { chunks } |
| 応答の最初の音声デルタ | audio.out.first { response_id, bytes, since_response_created_ms, since_speech_stopped_ms, play_state } |
response.done | audio.out { response_id, status, deltas, bytes, audio_ms, wall_ms, max_gap_ms, queued_ms, underruns, drain_ms_max } |
| 割り込みによる停止 | play.stop { reason, dropped_ms } |
| ユーザーによる停止 | stop { by: "client", phase }、続けてフラッシュ |
| トークンルート(サーバー) | server.token { ok, status, ms, upstream } |
メッセージハンドラーでは、イベントが音声デルタの場合はバイト数と間隔をカウントして再生します。それ以外の場合は、現在のフェーズとともに server としてログに記録してから、既存の処理を実行します。input_audio_buffer.speech_stopped の speechStoppedT と、response.created の createdT を保持し、最初のデルタで両方の経過時間を報告します。プレーヤーでは、応答の途中で次にスケジュールされた時刻がすでに過去になっている場合にアンダーランをカウントし、最大の再生終了時間を追跡して、response.created でリセットします。
UI:音声ステータス行に ID を表示し(Listening · session ab12cd34)、音声エラーには (voice session <id>) を追加して、ユーザーが実行を特定できるようにします。
4. 検証
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
-H 'Content-Type: application/json' \
-d '{"sessionId":"smoke001","entries":[{"t":0,"ts":0,"kind":"start"}]}' # 204
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:<port>/api/voice/log \
-H 'Content-Type: application/json' -d '{"sessionId":"../x","entries":[]}' # 400
cat .voice-logs/smoke001.ndjson && rm .voice-logs/smoke001.ndjson
その後、ユーザーに引き継ぎます。ユーザーが実際のアプリでテストし、何を話し、何が聞こえ、いつ問題が発生したか、セッション ID を報告します。
ログの読み方
以下のいずれかを使います。どれもアプリのツールチェーンは不要です。
f=.voice-logs/<id>.ndjson
jq -r 'select(.kind|IN("start","token.ok","mic.ok","env","ws.open","ws.close","server.token","stop","error")) | "\(.t // .ts)ms \(.kind) \(.type // "") \(.message // "")"' $f # milestones and errors
jq -r 'select(.kind=="server") | .type' $f | sort | uniq -c | sort -rn # server event counts
jq -c 'select(.kind|IN("audio.out.first","audio.out"))' $f # per-turn latency, gaps, underruns
jq -c 'select(.kind=="audio.in")' $f # mic windows, rms
または、ファイルを読みます。時間順に、1 行につき 1 つのオブジェクトが記録されています。サーバーのエントリーに含まれるのは ts のみです。start エントリーの ts を使って、クライアントのクロックに合わせます。ユーザーが要約コマンドを希望する場合は、アプリの言語で、マイルストーン、サーバーイベント数、audio.out ごとのターンとその audio.out.first のレイテンシー、マイクのウィンドウ集計、エラーと切断、直近 25 件のエントリーを出力するコマンドを作成します。
5. 修正のループ
- アプリに
.voice-logsパイプラインがまだない場合は、計測する(手順 1~4)。 - ユーザーがテストし、実行内容を説明します。
- 失敗した
tの前後のログを読む。 - 症状 → シグネチャ → 修正を照合する(下の表)。一致するシグネチャがない場合は、まずログを追加して再テストしてから修正します。
- 1 つだけ修正し、再テストして、新しいログでシグネチャがなくなったことを確認します。
- 記録する:「セッションから確認済み」の下に確認済みの行を追加します。修正によって音声の構築方法が変わる場合は、
/add-voiceも更新します。
症状 → ログのシグネチャ → 修正(初期行)
| 症状(ユーザー) | シグネチャ(ログ) | 修正 |
|---|---|---|
| 音声は聞こえないが、文字起こしは表示される | env.play_state または audio.out.first.play_state = suspended | ユーザー操作の中で再生用のオーディオコンテキストを作成して再開します。ターンごとではなく、セッションごとに1つ作成します |
| アシスタントが自分自身に割り込む | phase=speaking の speech_started。audio.in.rms_max が再生中にのみ上昇する | エコーです。ヘッドフォンを使って確認します(止まる場合はエコーです)。エコーキャンセルをオンのままにし、スピーカーの音量を下げるか、speaking 中はマイクの送信を抑制します |
| 音声が途切れる、ブツブツする | audio.out.underruns > 0、drain_ms_max が高い、max_gap_ms がチャンク長を大幅に上回る | 最初のチャンクを再生する前に、短い先行時間(150–250 ms)を設けます。ターンごとにオーディオコンテキストを作り直さないでください |
| 音が割れる、ピッチや速度がおかしい | session.update のレート ≠ バッファのレート。不自然な bytes | すべての箇所で同じレートを使います(audio.input/output.format.rate、プレーヤーバッファ)。バイト数を偶数に揃えます |
| まったく接続しない、またはすぐに切断される | session.updated より前に ws.close。server.token.ok=false | クリックごとにトークンを発行します(300 s)。プロトコルは xai-client-secret.<token>、URL に model を指定します。server.token.status と upstream を確認します |
| マイクが反応しない | すべてのウィンドウで audio.in.rms_max ≈ 0。mic.ok.label が想定外 | デバイスが間違っているか、OS の権限がありません。mic.ok.settings、label、mic_state を確認します |
| ユーザーの文字起こしが表示されない | conversation.item.input_audio_transcription.updated がない | session.update に audio.input.transcription.model: "grok-transcribe" を設定します |
| 最初の単語が出るまで時間がかかる | audio.out.first.since_speech_stopped_ms が高い | reasoning.effort: "none" を試します。instructions を短くします。接続にかかる時間は token.ok.ms と ws.open.ms で確認します |
| 返信の後にユーザーのテキストが表示される | response.created の t より ...transcription.updated の t が大きい | input_audio_buffer.committed(item_id)でユーザー行を作成し、updated で内容を入力します |
| 最初の単語が途切れる | ws.open.ms が大きく、audio.flush.chunks がバッファ上限に達している | ソケットの接続前にマイクを開始し、初期の音声をバッファリングして、接続前の上限を引き上げます |
セッションで確認済みの内容
修正を新しいログで確認した後に追記します。形式:YYYY-MM-DD · symptom · signature · fix · file(s)。
ルール
- まず計画を立てます。ユーザーが変更リストに合意する前に編集しないでください。
- トークン、キー、生の音声は記録しません。音声はバイト数に変換します。
- 開発環境専用です。
VOICE_LOG=1が設定されていない場合、本番環境ではシンクは 404 を返します。.voice-logs/は gitignore に登録されています。 - ログ記録によって音声処理の経路で例外が発生することはありません。
- 音声は集約して記録します。チャンクごとのログは記録しません。
- 再テストごとに変更は1つにします。これにより、どの修正が効いたかをログから判断できます。
- xAI のイベント名を創作しないでください。シグネチャを追加する前に https://docs.x.ai/developers/model-capabilities/audio/speech-to-speech で確認します。