リアルタイム音声AIをデモから実用へ進めると、次の問題にぶつかります。
AIの説明中にユーザーが「うん」と言っただけで、読み上げが止まってしまう。
一方で、本当に質問を差し込みたいときや「ストップ」と言ったときは、すぐ止まってほしい。ここで必要なのは、単純な音声検出でも、さらに賢いLLMでもありません。
必要なのは、誰が発話権を持つかをアプリケーション側で決める制御層です。
本記事では、音声AIの入力を次の3種類へ分ける小さなTurn ArbiterをTypeScriptで実装します。
- 「うん」「なるほど」などの相槌:AIの読み上げを再開
- 新しい質問や訂正:現在の読み上げを破棄して次のターンへ移る
- 「ストップ」などの停止要求:LLMを通さず即座に停止
高精度なSTTは文字起こしの品質を改善できます。しかし、その文字列が相槌なのか発話権の取得なのかは、製品側の判断です。ここを分けると、「AIが賢くない」という曖昧な不満を、検証可能な状態遷移の問題へ置き換えられます。
結論:音声認識結果をそのまま割り込み命令にしない
リアルタイム音声AIでは、次のパイプラインを一つの箱として扱わないことが重要です。
ユーザー音声
↓
RTC/メディア転送
↓
STT
↓
Turn Arbiter ← 今回実装する部分
├─ 相槌 → TTSを再開
├─ 発話権取得 → TTSを破棄してLLMへ送信
└─ 停止要求 → LLMを通さず停止
↓
LLM
↓
TTS
↓
ユーザーへ再生
Tencent Conversational AIは、ユーザーとLLMを接続するリアルタイム音声対話シナリオを提供しています。全体像は公式ドキュメントで確認できます。
また、OpenAI互換モデルやエージェントプラットフォームとの接続、リクエスト識別子を用いたルーティングと観測については、次の公式資料が実装上の参照になります。
本記事のコードは特定SDKのイベント名を仮定せず、STT・LLM・TTSの間へ置くアプリケーション制御層として実装します。実際の接続時には、利用するSDKから受け取ったイベントを、この制御層のイベントへ変換してください。
前提:AIに任せる判断と、任せない判断を分ける
LLMが得意なのは、確定した発話の意味を理解し、文脈に沿った回答を生成することです。一方、次の判断を毎回LLMへ問い合わせると、遅延と不確実性が増えます。
- 今流れている音声を止めるか
- 古い回答を再生してよいか
- 停止要求を受理するか
- 認識結果が届かなかったときにどう復旧するか
今回は、発話中の入力を次の表で処理します。
| 入力 | 判定 | TTS | LLM |
|---|---|---|---|
うん、はい、なるほど
|
相槌 | 一時保留後に再開 | 呼ばない |
えっと、あの
|
フィラー | 一時保留後に再開 | 呼ばない |
ストップ、止めて
|
明示停止 | 破棄 | 呼ばない |
別の話をして |
発話権取得 | 破棄 | 新しいターンとして呼ぶ |
| 音声はあるが確定文字列が来ない | 判定不能 | いったん保留、期限後に復旧 | 呼ばない |
相槌辞書を万能な自然言語分類器にする必要はありません。最初は対象言語とシナリオを限定し、誤判定ログから更新します。
特に重要なのは、停止操作をLLMの意味理解に依存させないことです。音声による停止に加えて、画面上にも常時操作可能な停止ボタンを用意します。
手順1:検証環境を作る
Node.js 20以降を前提にします。
mkdir voice-turn-arbiter
cd voice-turn-arbiter
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
今回は外部のSTTやLLMを呼ばず、イベント列から制御結果を再現します。先に制御層だけを確定させることで、モデル変更とターン制御変更を分離できます。
手順2:状態を4つに限定する
Turn Arbiterが持つ状態は次の4つです。
idle
AI音声を再生していない
speaking
AI音声を再生中
holding
ユーザー音声を検出し、AI音声を一時保留中
awaiting_final
発話権取得の可能性が高いためAI音声は破棄済みだが、
STTの確定結果を待っている
speakingから直接LLMへ移動させず、いったんholdingを挟むのがポイントです。
保留機能の実現方法は利用する音声出力層によって異なります。一時停止できない構成では、HOLD_TTSを音量抑制などへ変換する選択肢があります。ただし、その切り替えはアダプター内に閉じ込め、状態機械の意味は変えません。
コード:相槌・割り込み・停止を分岐する
src/turn-arbiter.tsを作成します。
export type Event =
| {
type: "assistant_started";
audioId: string;
generation: number;
}
| {
type: "assistant_finished";
audioId: string;
}
| {
type: "speech_started";
atMs: number;
}
| {
type: "transcript";
atMs: number;
text: string;
final: boolean;
}
| {
type: "decision_timeout";
atMs: number;
}
| {
type: "final_timeout";
atMs: number;
};
export type Action =
| { type: "HOLD_TTS"; audioId: string }
| { type: "RESUME_TTS"; audioId: string }
| { type: "CANCEL_TTS"; audioId: string; reason: string }
| {
type: "SEND_LLM";
text: string;
requestId: string;
generation: number;
}
| { type: "ASK_REPEAT"; reason: string }
| { type: "DROP_STALE"; targetId: string };
type Mode = "idle" | "speaking" | "holding" | "awaiting_final";
const ACKNOWLEDGEMENTS = new Set([
"うん",
"はい",
"なるほど",
"そうなんだ",
"へえ",
]);
const FILLERS = new Set([
"えっと",
"ええと",
"あの",
"その",
]);
const STOP_PHRASES = new Set([
"ストップ",
"止めて",
"もういい",
]);
function normalize(text: string): string {
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[\s、。!?!?.,]/g, "");
}
export class TurnArbiter {
private mode: Mode = "idle";
private generation = 0;
private requestSequence = 0;
private activeAudioId?: string;
private holdStartedAt?: number;
private pendingText = "";
snapshot() {
return {
mode: this.mode,
generation: this.generation,
activeAudioId: this.activeAudioId,
pendingText: this.pendingText,
};
}
input(event: Event): Action[] {
switch (event.type) {
case "assistant_started":
// 割り込み前の古いLLM結果から作られた音声は再生しない。
if (event.generation !== this.generation) {
return [{ type: "DROP_STALE", targetId: event.audioId }];
}
this.mode = "speaking";
this.activeAudioId = event.audioId;
return [];
case "assistant_finished":
if (event.audioId !== this.activeAudioId) {
return [{ type: "DROP_STALE", targetId: event.audioId }];
}
this.resetToIdle();
return [];
case "speech_started":
if (this.mode !== "speaking" || !this.activeAudioId) {
return [];
}
this.mode = "holding";
this.holdStartedAt = event.atMs;
this.pendingText = "";
return [{ type: "HOLD_TTS", audioId: this.activeAudioId }];
case "transcript":
return this.onTranscript(event);
case "decision_timeout":
return this.onDecisionTimeout(event.atMs);
case "final_timeout":
if (this.mode !== "awaiting_final") {
return [];
}
this.resetToIdle();
return [
{
type: "ASK_REPEAT",
reason: "STTの確定結果を受信できませんでした",
},
];
}
}
private onTranscript(
event: Extract<Event, { type: "transcript" }>,
): Action[] {
const text = normalize(event.text);
if (this.mode === "idle") {
if (!event.final || text.length < 2) return [];
this.generation += 1;
return [this.createLlmAction(text)];
}
if (this.mode === "awaiting_final") {
if (!event.final) return [];
if (text.length < 2) {
this.resetToIdle();
return [
{ type: "ASK_REPEAT", reason: "発話内容を確定できませんでした" },
];
}
this.resetToIdle(false);
return [this.createLlmAction(text)];
}
if (this.mode !== "holding" || !this.activeAudioId) {
return [];
}
if (text) this.pendingText = text;
// 停止要求はLLMへ送らず、暫定結果でも決定的に処理する。
if (STOP_PHRASES.has(text)) {
const audioId = this.activeAudioId;
this.invalidateCurrentTurn();
this.resetToIdle(false);
return [
{
type: "CANCEL_TTS",
audioId,
reason: "explicit_stop",
},
];
}
if (!event.final) return [];
if (
ACKNOWLEDGEMENTS.has(text) ||
FILLERS.has(text) ||
text.length < 2
) {
const audioId = this.activeAudioId;
this.mode = "speaking";
this.pendingText = "";
this.holdStartedAt = undefined;
return [{ type: "RESUME_TTS", audioId }];
}
// 相槌以外の確定発話は、新しいターンとして扱う。
const audioId = this.activeAudioId;
this.invalidateCurrentTurn();
this.resetToIdle(false);
return [
{
type: "CANCEL_TTS",
audioId,
reason: "user_took_floor",
},
this.createLlmAction(text),
];
}
private onDecisionTimeout(atMs: number): Action[] {
if (
this.mode !== "holding" ||
!this.activeAudioId ||
this.holdStartedAt === undefined
) {
return [];
}
if (atMs - this.holdStartedAt < 800) return [];
// 長い暫定文字列があるなら発話権取得の可能性が高い。
// TTSは破棄するが、LLMへは確定結果が来るまで送らない。
if (this.pendingText.length >= 4) {
const audioId = this.activeAudioId;
this.invalidateCurrentTurn();
this.mode = "awaiting_final";
return [
{
type: "CANCEL_TTS",
audioId,
reason: "probable_interruption",
},
];
}
// 音だけ検出し、意味のある文字列が得られなければ再開する。
const audioId = this.activeAudioId;
this.mode = "speaking";
this.pendingText = "";
this.holdStartedAt = undefined;
return [{ type: "RESUME_TTS", audioId }];
}
private createLlmAction(text: string): Action {
this.requestSequence += 1;
return {
type: "SEND_LLM",
text,
generation: this.generation,
requestId: `voice:${this.generation}:${this.requestSequence}`,
};
}
private invalidateCurrentTurn() {
this.generation += 1;
this.activeAudioId = undefined;
}
private resetToIdle(clearAudio = true) {
this.mode = "idle";
this.pendingText = "";
this.holdStartedAt = undefined;
if (clearAudio) this.activeAudioId = undefined;
}
}
手順3:イベントを再生して動作を確認する
src/test.tsを作成します。
import assert from "node:assert/strict";
import { TurnArbiter, type Action } from "./turn-arbiter.js";
function types(actions: Action[]): string[] {
return actions.map((action) => action.type);
}
// ケース1:「うん」は割り込みではない。
{
const arbiter = new TurnArbiter();
arbiter.input({
type: "assistant_started",
audioId: "audio-1",
generation: 0,
});
assert.deepEqual(
types(arbiter.input({ type: "speech_started", atMs: 100 })),
["HOLD_TTS"],
);
assert.deepEqual(
types(
arbiter.input({
type: "transcript",
atMs: 250,
text: "うん",
final: true,
}),
),
["RESUME_TTS"],
);
}
// ケース2:新しい依頼ならTTSを破棄し、LLMへ送る。
{
const arbiter = new TurnArbiter();
arbiter.input({
type: "assistant_started",
audioId: "audio-2",
generation: 0,
});
arbiter.input({ type: "speech_started", atMs: 100 });
const actions = arbiter.input({
type: "transcript",
atMs: 400,
text: "別の話をして",
final: true,
});
assert.deepEqual(types(actions), ["CANCEL_TTS", "SEND_LLM"]);
assert.equal(actions[1].type === "SEND_LLM" && actions[1].generation, 1);
}
// ケース3:「ストップ」はLLMへ送らない。
{
const arbiter = new TurnArbiter();
arbiter.input({
type: "assistant_started",
audioId: "audio-3",
generation: 0,
});
arbiter.input({ type: "speech_started", atMs: 100 });
const actions = arbiter.input({
type: "transcript",
atMs: 180,
text: "ストップ",
final: false,
});
assert.deepEqual(types(actions), ["CANCEL_TTS"]);
}
// ケース4:長い暫定結果では再生を止め、確定結果を待つ。
{
const arbiter = new TurnArbiter();
arbiter.input({
type: "assistant_started",
audioId: "audio-4",
generation: 0,
});
arbiter.input({ type: "speech_started", atMs: 0 });
arbiter.input({
type: "transcript",
atMs: 500,
text: "明日の予定",
final: false,
});
assert.deepEqual(
types(arbiter.input({ type: "decision_timeout", atMs: 900 })),
["CANCEL_TTS"],
);
assert.deepEqual(
types(
arbiter.input({
type: "transcript",
atMs: 1_000,
text: "明日の予定を教えて",
final: true,
}),
),
["SEND_LLM"],
);
// 割り込み前の世代から遅れて届いた音声は再生しない。
assert.deepEqual(
types(
arbiter.input({
type: "assistant_started",
audioId: "stale-audio",
generation: 0,
}),
),
["DROP_STALE"],
);
}
console.log("all turn-arbiter tests passed");
実行します。
npx tsx src/test.ts
次の表示になれば、ローカルの状態遷移は確認できています。
all turn-arbiter tests passed
手順4:Tencent Conversational AIとの境界を決める
Tencent Conversational AIへ接続する際は、製品固有の処理をTurn Arbiterへ直接書き込まず、アダプターで変換します。
type VoiceRuntime = {
holdAudio(audioId: string): Promise<void>;
resumeAudio(audioId: string): Promise<void>;
cancelAudio(audioId: string): Promise<void>;
sendToLlm(input: {
text: string;
requestId: string;
generation: number;
}): Promise<void>;
askUserToRepeat(reason: string): Promise<void>;
};
async function executeAction(
action: Action,
runtime: VoiceRuntime,
): Promise<void> {
switch (action.type) {
case "HOLD_TTS":
await runtime.holdAudio(action.audioId);
return;
case "RESUME_TTS":
await runtime.resumeAudio(action.audioId);
return;
case "CANCEL_TTS":
await runtime.cancelAudio(action.audioId);
return;
case "SEND_LLM":
await runtime.sendToLlm({
text: action.text,
requestId: action.requestId,
generation: action.generation,
});
return;
case "ASK_REPEAT":
await runtime.askUserToRepeat(action.reason);
return;
case "DROP_STALE":
return;
}
}
接続先のLLMには、公式ドキュメントに従ってOpenAI互換モデルなどを設定します。requestIdは、STT確定結果、LLM要求、TTS生成、再生結果を関連付けるアプリケーション側の識別子として扱います。
重要なのは、LLMの回答が返った時点で即再生しないことです。回答に対応するgenerationが現在値と一致する場合だけTTSへ渡します。ユーザーが割り込んだ後に古い回答が到着しても、会話へ復帰させてはいけません。
AIコンパニオンや1対1の会話体験を設計する場合は、対象シナリオの位置付けも公式ソリューションで確認できます。
確認方法:回答の自然さより先に制御経路を壊す
実際のSTT・LLM・TTSへ接続したら、次の順番で確認します。
1. 相槌で読み上げが破棄されない
AI: 明日の天気について説明すると……
User: うん
AI: (続きから再開)
確認項目:
-
HOLD_TTSの後にRESUME_TTSが出る -
SEND_LLMが発生しない - 相槌が会話履歴のユーザー要求として追加されない
2. 訂正では古い音声を再開しない
AI: 最初の候補は……
User: いや、電車で行く場合を教えて
確認項目:
- 現在のTTSが破棄される
- 新しい
generationでLLM要求が作られる - 古いLLM結果やTTS完了イベントが
DROP_STALEになる
3. 停止要求がLLM障害の影響を受けない
LLMへの通信を意図的に失敗させた状態で「ストップ」と発話します。
確認項目:
- LLMを呼ばずに音声が停止する
- 画面上の停止ボタンでも同じ制御経路を通る
- 停止後にキュー済みの音声が再生されない
4. STTの確定結果が欠落しても無言で固まらない
長い暫定結果を受信した後、確定結果を送らずfinal_timeoutを発生させます。
確認項目:
- 推測した暫定文字列をLLMへ送らない
- ユーザーへ再発話を求める
- 復旧後に新しいターンを開始できる
5. 実環境では時刻も記録する
最低限、次の時刻を単調増加時計で記録します。
speech_started_at
first_transcript_at
final_transcript_at
hold_tts_at
cancel_or_resume_at
llm_request_at
first_audio_ready_at
「速かった」という感想だけではなく、どこで待っているかを分解できます。ただし、固定の合格値を万能な基準にしてはいけません。端末、ネットワーク、言語、TTSの再生方式ごとに実測し、許容範囲を決めます。
AIに任せる範囲を決める
この構成でAIが改善できるのは、文字起こし後の意図理解や回答生成です。対して、人間が決めるべきなのは次の部分です。
| 判断 | 担当 |
|---|---|
| 回答文の生成 | LLM |
| 相槌辞書と停止語 | プロダクト担当者 |
| どの操作に確認が必要か | 運営・セキュリティ担当者 |
| 誤判定時の復旧文 | 会話設計担当者 |
| 保存するログと保持期間 | 組織のプライバシーポリシー |
| 有人対応へ切り替える条件 | 実際に対応を所有するチーム |
とくに有人対応は、「何かあれば問い合わせてください」という新しい窓口を増やすのではなく、既存の運用キューへ接続します。誰も所有しないフィードバックチャンネルは、音声AIの品質問題を見えなくするだけです。
注意点とトレードオフ
相槌辞書はユーザー層によって変わる
「そう」は相槌にも訂正にもなります。短い語を大量に相槌辞書へ加えるほど、ユーザーが発話権を取りにくくなります。
辞書を更新するときは、次の分類を人が確認してください。
false interruption:
相槌なのにAIを止めた
missed interruption:
新しい要求なのにAIが話し続けた
両方を別々に集計しないと、片方だけを改善してもう片方を悪化させます。
暫定文字列をそのままLLMへ送らない
暫定結果は後から修正される可能性があります。早く送れば待ち時間は短く見えますが、誤認識された要求に対するLLM処理やTTS生成が増えます。
本実装では、長い暫定結果を「TTSを止める根拠」には使いますが、「LLMへ送る本文」には使いません。速度と正確性の境界をここで分けています。
音声停止には画面操作も用意する
STTが停止語を認識できない状況はあります。音声だけを唯一の停止手段にせず、ユーザーがいつでも押せる停止・ミュート・退出操作を用意してください。
秘密情報をクライアントへ置かない
LLMなど外部サービスの認証情報はサーバー側で管理します。また、音声や全文文字起こしを観測ログへ無条件に保存しないでください。通常ログには、リクエスト識別子、状態遷移、処理時間、失敗分類を残し、本文保存は同意、目的、保持期間を別途定めます。
ツール実行は会話生成と分離する
LLMが予定変更、購入、送信などの副作用を伴う操作を提案できる場合でも、音声認識結果だけで実行しないでください。対象、内容、影響をユーザーへ読み返し、画面または明示発話で確認する段階を追加します。
まとめ
音声AIが相槌で黙る問題は、必ずしもSTTやLLMの性能不足ではありません。多くの場合、認識結果をそのまま割り込み命令へ変換していることが原因です。
実装上は、次の分離が効きます。
- 音声検出時は、まずTTSを保留する
- 相槌なら再開する
- 新しい確定発話なら古い世代を破棄する
- 停止要求はLLMを通さない
- 確定結果が欠落したら、推測せず再発話を求める
- リクエスト識別子と世代番号で遅延結果を捨てる
高精度な音声認識や高速なLLMは有用です。しかし、それらを信頼できる会話へ変えるのは、ユーザーの発話権を決定的に扱うアプリケーション設計です。
関係開示: 筆者はTencent RTCに関係する立場で本記事を執筆しています。実装上の事実確認にはTencent RTCの公式ドキュメントを参照しました。