リアルタイム音声AIを作ると、LLMの回答品質より先に困ることがあります。
ユーザーが「うん」と相づちを打つたびにAIが黙る。一方で「待って」と止めても読み上げが続く。停止後に生成済みのTTS音声が遅れて再生されることもあります。
ここでの緊張は、会話を自然にしたいほど、誰が話す権利を持つかが曖昧になることです。LLMやTTSを接続できることと、自然な順番で話せることは別の能力です。
本稿では、次の4つをアプリ側で判定するTypeScript実装を作ります。
- 短い相づちはAIの読み上げを継続する
- 「待って」「止めて」は即時停止する
- 内容のある継続発話ではユーザーへ発話権を渡す
- 停止後に到着した古いTTS音声は再生しない
結論:割り込み判定をLLMへ任せず、短い決定表にする
音声AIの割り込みは、次の順序で判断すると挙動を説明しやすくなります。
| 優先度 | 入力 | 動作 |
|---|---|---|
| 1 | 画面の停止ボタン | TTSと回答生成を停止 |
| 2 | 「待って」など明示的な停止語 | 即時停止してユーザーへ発話権を渡す |
| 3 | 内容のある発話が一定時間続く | AIを停止してユーザーへ発話権を渡す |
| 4 | 「うん」「なるほど」など短い相づち | AIの読み上げを継続 |
| 5 | 停止済み回答のTTSチャンク | 破棄 |
即時停止の経路へLLM分類を入れないのがポイントです。LLMは曖昧な発話の整理には使えますが、停止判断へ入れると応答待ち、出力揺れ、障害時の扱いが増えます。
現在の音声AIはLLMとTTSを組み合わせて会話を生成できます。しかし、「うん」が同意なのか、単なる相づちなのかを常に正しく判断できる、という意味ではありません。最終的に決める必要があるのはモデルの賢さではなく、誤停止と停止遅れのどちらを優先するかというサービス側の方針です。
前提:音声経路と発話権の状態を分離する
構成要素を一つのAIとして扱わず、次の責任に分けます。
ユーザー音声
↓
RTC/メディア経路
↓
音声認識(部分テキスト・確定テキスト)
↓
ターン制御 ← 停止ボタン
├─ 継続 → 現在のTTSを再生
└─ 割り込み → TTS停止・LLM停止・ユーザーへ発話権
↓
LLMへ確定発話を送信
↓
TTS音声をRTC経路へ返す
Tencent Conversational AIは、ユーザーとLLMを使ったリアルタイム音声対話のシナリオを扱います。概要は公式ドキュメントで確認できます。
LLM設定ではOpenAI互換モデルやエージェント基盤との接続、リクエスト識別子を使ったルーティングと観測について案内されています。本稿のresponseIdは製品固有のAPI名ではなく、アプリ内の回答世代を追跡するための識別子です。実際の接続時は、公式ドキュメントにある識別情報とアプリのresponseIdをログ上で対応付けます。
手順1:最小プロジェクトを作る
Node.js環境で、TypeScriptを直接実行できる検証用プロジェクトを用意します。
mkdir voice-turn-arbiter
cd voice-turn-arbiter
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
今回はSDKへ接続する前に、ターン制御だけを固定イベントで検証します。RTC、音声認識、LLM、TTSを同時に接続してから調整すると、どの層が停止させたのか分からなくなるためです。
手順2:相づちと割り込みを状態機械にする
src/turn-arbiter.tsを作成します。
import assert from "node:assert/strict";
type Input =
| { kind: "ai_started"; responseId: string; at: number }
| { kind: "voice_started"; at: number }
| { kind: "transcript"; text: string; final: boolean; at: number }
| { kind: "voice_ended"; at: number }
| { kind: "stop_pressed"; at: number }
| { kind: "tts_chunk"; responseId: string; at: number };
type Action =
| "PLAY_AUDIO"
| "DROP_LATE_AUDIO"
| "HOLD_AI_FLOOR"
| "STOP_PLAYBACK"
| "CANCEL_RESPONSE"
| "GRANT_USER_FLOOR"
| "COMMIT_USER_TURN"
| "WAIT_FOR_USER";
type Policy = {
takeoverMs: number;
backchannelMaxMs: number;
backchannels: ReadonlySet<string>;
hardStops: readonly string[];
};
const defaultPolicy: Policy = {
// 検証開始用のアプリ設定であり、製品性能値ではない
takeoverMs: 450,
backchannelMaxMs: 700,
backchannels: new Set([
"うん",
"はい",
"ええ",
"なるほど",
"そうなんだ",
]),
hardStops: ["待って", "止めて", "ストップ"],
};
const normalize = (text: string): string =>
text
.normalize("NFKC")
.replace(/[、。!?!?\s]/g, "")
.toLowerCase();
export class TurnArbiter {
private aiResponseId?: string;
private voiceStartedAt?: number;
private latestText = "";
private readonly interrupted = new Set<string>();
constructor(private readonly policy: Policy = defaultPolicy) {
if (
!Number.isFinite(policy.takeoverMs) ||
!Number.isFinite(policy.backchannelMaxMs) ||
policy.takeoverMs < 0 ||
policy.backchannelMaxMs < 0
) {
throw new Error("時間の設定には0以上の有限値が必要です");
}
}
handle(input: Input): Action[] {
if (!Number.isFinite(input.at)) {
throw new Error("イベント時刻には有限値が必要です");
}
switch (input.kind) {
case "ai_started":
this.aiResponseId = input.responseId;
this.resetUserSpeech();
return [];
case "tts_chunk":
return input.responseId === this.aiResponseId &&
!this.interrupted.has(input.responseId)
? ["PLAY_AUDIO"]
: ["DROP_LATE_AUDIO"];
case "stop_pressed":
return this.interrupt();
case "voice_started":
this.voiceStartedAt = input.at;
this.latestText = "";
return [];
case "transcript": {
this.latestText = normalize(input.text);
if (!this.aiResponseId) {
if (!input.final || !this.latestText) return [];
return this.isOnlyHardStop(this.latestText)
? ["WAIT_FOR_USER"]
: ["COMMIT_USER_TURN"];
}
if (this.containsHardStop(this.latestText)) {
return this.interrupt();
}
const duration = this.speechDuration(input.at);
const isBackchannel = this.policy.backchannels.has(this.latestText);
if (
this.latestText &&
duration >= this.policy.takeoverMs &&
!isBackchannel
) {
return this.interrupt();
}
return [];
}
case "voice_ended": {
const duration = this.speechDuration(input.at);
const text = this.latestText;
const isBackchannel = this.policy.backchannels.has(text);
this.resetUserSpeech();
if (!this.aiResponseId) {
if (!text) return [];
return this.isOnlyHardStop(text)
? ["WAIT_FOR_USER"]
: ["COMMIT_USER_TURN"];
}
if (isBackchannel && duration <= this.policy.backchannelMaxMs) {
return ["HOLD_AI_FLOOR"];
}
return text ? this.interrupt() : [];
}
}
}
private speechDuration(at: number): number {
if (this.voiceStartedAt === undefined) return 0;
return Math.max(0, at - this.voiceStartedAt);
}
private containsHardStop(text: string): boolean {
return this.policy.hardStops.some((word) => text.includes(word));
}
private isOnlyHardStop(text: string): boolean {
return this.policy.hardStops.some((word) => text === word);
}
private interrupt(): Action[] {
if (!this.aiResponseId) return ["GRANT_USER_FLOOR"];
this.interrupted.add(this.aiResponseId);
this.aiResponseId = undefined;
return [
"STOP_PLAYBACK",
"CANCEL_RESPONSE",
"GRANT_USER_FLOOR",
];
}
private resetUserSpeech(): void {
this.voiceStartedAt = undefined;
this.latestText = "";
}
}
// ---- 再現テスト ----
const backchannel = new TurnArbiter();
backchannel.handle({ kind: "ai_started", responseId: "r-1", at: 0 });
backchannel.handle({ kind: "voice_started", at: 100 });
backchannel.handle({
kind: "transcript",
text: "うん",
final: true,
at: 250,
});
assert.deepEqual(
backchannel.handle({ kind: "voice_ended", at: 300 }),
["HOLD_AI_FLOOR"],
);
assert.deepEqual(
backchannel.handle({ kind: "tts_chunk", responseId: "r-1", at: 320 }),
["PLAY_AUDIO"],
);
const hardStop = new TurnArbiter();
hardStop.handle({ kind: "ai_started", responseId: "r-2", at: 0 });
hardStop.handle({ kind: "voice_started", at: 100 });
assert.deepEqual(
hardStop.handle({
kind: "transcript",
text: "待って",
final: false,
at: 180,
}),
["STOP_PLAYBACK", "CANCEL_RESPONSE", "GRANT_USER_FLOOR"],
);
assert.deepEqual(
hardStop.handle({ kind: "tts_chunk", responseId: "r-2", at: 220 }),
["DROP_LATE_AUDIO"],
);
const takeover = new TurnArbiter();
takeover.handle({ kind: "ai_started", responseId: "r-3", at: 0 });
takeover.handle({ kind: "voice_started", at: 100 });
assert.deepEqual(
takeover.handle({
kind: "transcript",
text: "それは別の話で",
final: false,
at: 650,
}),
["STOP_PLAYBACK", "CANCEL_RESPONSE", "GRANT_USER_FLOOR"],
);
assert.throws(
() => takeover.handle({ kind: "voice_started", at: Number.NaN }),
/有限値/,
);
console.log("all turn-arbitration checks passed");
実行します。
npx tsx src/turn-arbiter.ts
次の表示になれば、固定シナリオは通過しています。
all turn-arbitration checks passed
Number.isFiniteを使う理由
音声イベントは複数の非同期処理から届きます。時刻がundefinedや文字列になったまま計算へ入ると、割り込み時間の比較が成立しません。
!Number.isNaN(value)は文字列やnullを数値として保証しないため、ここでは「有限なnumberであること」をNumber.isFiniteで検査しています。ただし、有限値であるだけでは単調増加やイベント順序までは保証できません。実運用では後述する順序検査も追加します。
手順3:実際の音声経路へ接続する
SDK固有のAPIをターン制御クラスへ直接書かず、アダプターで変換します。
const actions = arbiter.handle({
kind: "transcript",
text: recognizedText,
final: isFinal,
at: monotonicNowMs(),
});
for (const action of actions) {
switch (action) {
case "STOP_PLAYBACK":
await audioOutput.stop();
break;
case "CANCEL_RESPONSE":
await llmSession.cancelCurrentResponse();
break;
case "GRANT_USER_FLOOR":
ui.showListening();
break;
case "COMMIT_USER_TURN":
await llmSession.sendFinalTranscript(recognizedText);
break;
}
}
上記のaudioOutputやllmSessionは説明用のアプリ内インターフェースで、Tencent RTCのAPI名ではありません。利用するSDKや構成に合わせて実装してください。
接続時のワークフローは次のようにします。
- セッション開始時に
conversationIdを生成する - LLMへ送るターンごとに
responseIdを生成する - Tencent Conversational AI側のリクエスト識別情報と対応付ける
- TTS音声チャンクへ同じ
responseIdを関連付ける - 割り込み済み
responseIdの音声を再生直前にも拒否する
停止命令を送るだけでは不十分です。すでにネットワークや再生キューへ入った音声が後から届く可能性を考え、出力直前にも回答世代を検査する必要があります。
AIコンパニオンやキャラクター対話を含むシナリオの位置付けは、公式のSocial Entertainment solutionでも確認できます。
手順4:会話ポリシーをサービスごとに決める
本稿の450msや700msは検証開始用の値であり、推奨性能値ではありません。実際には、サービスの目的に応じて調整します。
| サービス | 優先すること | 初期方針 |
|---|---|---|
| 操作アシスタント | 停止要求を取りこぼさない | 停止語を多めにし、画面停止を常時表示 |
| 雑談コンパニオン | 相づちで会話が途切れない | 短い相づちは継続。ただし長い発話は譲る |
| 学習・面接練習 | ユーザーが考える時間を守る | AIの再発話を急がず、明示的な発話権表示を出す |
| 読み上げ中心 | 誤停止を減らす | 音声だけでなく停止ボタンを主要経路にする |
相づち辞書を無制限に増やすのは避けます。例えば「そう」は相づちにも訂正の開始にもなります。曖昧な語を固定辞書へ追加すると、今度は本当の割り込みを無視します。
迷う場合は、次の順で改善します。
- 明示的な停止ボタンを提供する
- 停止語だけを即時判定する
- 発話継続時間を加える
- 実際の誤判定を確認して相づち辞書を限定的に更新する
- それでも曖昧な発話だけ、発話権取得後にLLMで意図整理する
LLMは5番目の補助には使えますが、1〜3番目の安全な停止経路の代わりにはしません。
確認方法:自然に聞こえるかより、発話権が一意かを調べる
自動確認
最低限、次のケースを追加します。
- 「うん」の後も同じ
responseIdの音声を再生できる - 「待って」の部分認識で停止できる
- 「うん、でもそれは違う」は継続発話として割り込める
- 停止後に届いた同じ
responseIdの音声を破棄する - 次の回答の新しい
responseIdは再生できる -
NaN、Infinity、負方向へ戻る時刻を拒否する - 音声認識結果が空の咳や環境音だけでは即時停止しない
- 停止ボタンは音声認識が失敗していても動く
実機確認
イヤホンとスピーカー再生の両方で確認します。エコーや周囲の音によって音声認識の部分結果が変わるためです。
確認時は、次のイベントだけを時系列で記録すると原因を追いやすくなります。
{
"conversationId": "c-42",
"responseId": "r-8",
"event": "interrupt",
"reason": "hard_stop",
"monotonicMs": 18420,
"policyVersion": "turn-policy-3"
}
生の音声や全文を保存しなくても調査できるよう、まずはイベント種別、理由、時間、ポリシー版を記録します。テキスト保存が必要な場合は、利用目的、保持期間、アクセス権、ユーザー同意を別途決めます。
人が判定する項目
自動テストだけでは「自然な譲り方」は評価できません。録音または同意を得たテスト会話に対して、人が次をラベル付けします。
- 止めるべき発話を止められたか
- 相づちで誤停止しなかったか
- 停止後に古い音声が復活しなかったか
- AIが話していること、聞いていることがUIから分かったか
- ユーザーが音声以外の方法でも停止できたか
単一の「自然さスコア」にまとめず、誤停止と停止漏れを別々に数えるのが重要です。両者は同じ閾値を動かすと逆方向に変化するためです。
注意点
1. 停止語の部分一致には誤検知がある
「止めて」が別の文脈に含まれる場合でも停止します。即時性を優先した設計上のトレードオフです。誤検知が問題になるサービスでは、停止語を限定するか、画面操作を主要な停止手段にします。
2. 音声認識が遅れれば、テキストによる停止も遅れる
本稿の状態機械は認識済みテキストを処理します。音声認識前の遅延までは解消しません。ユーザーが確実に止められるUI操作を残してください。
3. 発話時間だけでは咳や環境音を判定できない
時間閾値のみで即時停止すると、生活音で会話が途切れます。本実装では空テキストだけでは停止しませんが、音声認識の誤認識は残ります。実際の利用環境で失敗例を集め、辞書を広げる前に原因を分けます。
4. キャンセルと音声破棄は両方必要
LLMやTTSのキャンセル要求が成功しても、すでに生成・配送された音声が残る場合に備えます。responseIdによる再生直前の検査を、キャンセルの代替ではなく二重の防御として使います。
5. AIコンパニオンにはユーザー可視の制御を残す
マイク状態、AIが話している状態、停止操作を画面へ表示します。モデレーション、プライバシー、録音や解析への同意も、会話の自然さとは別の要件として設計してください。
まとめ
LLM、TTS、リアルタイム音声を接続しただけでは、ターンテイキングは完成しません。
まず、相づち、明示停止、継続発話、停止済み音声を小さな決定表に分けます。その上で、回答ごとのresponseIdを使って古い音声を破棄し、停止ボタンを常に独立した経路として残します。
AIが改善できるのは、発話内容の理解や回答生成です。一方で、誰がいつ話せるか、どの誤判定を許容するか、ユーザーがどう停止できるかは、人が決めて検証すべき会話ポリシーです。
関係性の開示: 本稿はTencent RTCに関係するコンテンツとして執筆しており、実装上の参照資料としてTencent RTC公式ドキュメントを使用しました。