AI音声コンパニオンへ専門用語の読み方を教えたい。しかし、発話のたびにLLMへ読み仮名を問い合わせると、待ち時間と失敗箇所が増えます。一方、固定辞書だけでは新しい略語やサービス名に追いつけません。
この緊張関係は、LLMの精度だけでは解消できません。必要なのは、リアルタイム経路では承認済み辞書だけを使い、LLMは辞書更新の候補作成に限定する設計です。
本稿では、AI音声コンパニオンの表示文と読み上げ文を分離し、専門用語の発音を安全に改善するパイプラインをTypeScriptで実装します。
結論
実装方針は次の5点です。
- LLMが生成した原文を
displayTextとして保持する - TTSへ渡す直前に、承認済み辞書だけで
speechTextを作る - 未登録語は現在の会話では変換せず、集計だけ行う
- LLMは未登録語の読み候補を非同期に提案する
- 人間が承認した候補だけを辞書へ追加する
ユーザー音声
↓
音声認識
↓
会話LLM ─────────────→ displayText
↓
承認済み発音辞書
↓
TTS用speechText
↓
音声合成 → RTC経由で再生
未登録語
↓ 非同期
候補生成LLM → 人間の確認 → 発音辞書
LLMは、略語の意味や分野を踏まえた候補生成には役立ちます。しかし、組織固有の読み方や複数の慣用読みのどれを採用するかは決められません。そこはモデルの能力不足というより、正解が技術問題ではなく運用上の選択だからです。
前提:会話、発音、音声配信の責任を混ぜない
Tencent Conversational AIは、ユーザーとLLMをリアルタイム音声で接続するシナリオを扱います。全体像は公式ドキュメントを参照してください。
LLM設定ではOpenAI互換モデルやエージェントプラットフォームとの接続が案内されています。リクエスト識別子を使ったルーティングや観測も考慮されています。
ただし、次の責任はアプリケーション側でも分離して考えます。
| 層 | 責任 |
|---|---|
| RTC | 音声の送受信 |
| 音声認識 | ユーザー音声からテキストへの変換 |
| 会話LLM | 返答内容の生成 |
| 発音正規化 | 表示文からTTS用テキストへの変換 |
| TTS | テキストから音声への変換 |
| アプリ状態 | ターン、割り込み、辞書バージョンの管理 |
| 人間 | 読み方の承認、禁止語、安全方針の決定 |
本稿では、製品固有の未確認API名を仮定せず、発音正規化をアプリケーションの独立した部品として実装します。
なぜ発話ごとにLLM変換しないのか
候補生成LLMをTTS直前へ同期的に入れる方法は、デモでは魅力的です。しかし、本番の会話では次の問題が生じます。
| 方式 | 新語への対応 | 会話時の追加待ち時間 | 再現性 | 誤変換時の制御 |
|---|---|---|---|---|
| 固定辞書 | 低い | 小さい | 高い | しやすい |
| ルール・辞書の組み合わせ | 中程度 | 小さい | 高い | しやすい |
| 発話ごとにLLM変換 | 高い可能性 | 増える | 低い | 難しい |
| LLMで候補生成し、人間が辞書へ反映 | 高い | 会話経路では増えない | 高い | しやすい |
特にリアルタイム音声では、会話LLM、発音変換LLM、TTSのどこで待っているのか分かりにくくなります。さらに割り込み後に古い変換結果が返ると、停止したはずの回答が再び読み上げられる危険があります。
そこで、AIには「候補を考える仕事」を任せ、現在の音声へ採用する判断は辞書と人間に残します。
手順1:検証プロジェクトを作る
Node.js 20以降を前提にします。
mkdir voice-pronunciation-pipeline
cd voice-pronunciation-pipeline
npm init -y
npm pkg set type=module
npm install --save-dev typescript tsx @types/node
mkdir -p src test data
package.jsonへテストコマンドを追加します。
{
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
}
}
手順2:発音辞書に承認状態と適用条件を持たせる
読み方だけを保存すると、同じ表記に複数の読みがある場合に管理できません。最低限、辞書バージョン、適用条件、承認者を残します。
data/glossary.jsonを作成します。
{
"version": 3,
"entries": [
{
"surface": "WebRTC",
"spoken": "ウェブアールティーシー",
"policy": "always",
"contextHints": [],
"approvedBy": "voice-editor",
"approvedAt": "2026-08-01T00:00:00Z"
},
{
"surface": "SQL",
"spoken": "エスキューエル",
"policy": "context",
"contextHints": ["データベース", "クエリ"],
"approvedBy": "voice-editor",
"approvedAt": "2026-08-01T00:00:00Z"
},
{
"surface": "AcmeEdge",
"spoken": "アクミーエッジ",
"policy": "always",
"contextHints": [],
"approvedBy": "brand-owner",
"approvedAt": "2026-08-01T00:00:00Z"
}
]
}
SQLのように複数の読み方が使われる語は、一般論としてどちらが正しいかではなく、プロダクト内の方針として決めます。ブランド名は、ブランド管理者など読み方を決める権限を持つ人が承認します。
コード:表示文を残したままTTS用テキストを作る
src/normalizer.tsを作成します。
export type GlossaryEntry = {
surface: string;
spoken: string;
policy: "always" | "context";
contextHints: string[];
approvedBy: string;
approvedAt: string;
};
export type Glossary = {
version: number;
entries: GlossaryEntry[];
};
export type NormalizedSpeech = {
displayText: string;
speechText: string;
glossaryVersion: number;
appliedTerms: string[];
unknownTerms: string[];
};
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function shouldApply(entry: GlossaryEntry, text: string): boolean {
if (entry.policy === "always") return true;
return entry.contextHints.some((hint) => text.includes(hint));
}
function replaceInProse(
prose: string,
fullText: string,
entries: GlossaryEntry[],
applied: Set<string>
): string {
let result = prose;
// 短い語が長い語の一部を先に置換しないよう、長い表記から処理する
const sorted = [...entries].sort(
(a, b) => b.surface.length - a.surface.length
);
for (const entry of sorted) {
if (!shouldApply(entry, fullText)) continue;
const escaped = escapeRegExp(entry.surface);
const pattern = new RegExp(
`(^|[^A-Za-z0-9_])(${escaped})(?=$|[^A-Za-z0-9_])`,
"gi"
);
result = result.replace(pattern, (_match, prefix: string) => {
applied.add(entry.surface);
return `${prefix}${entry.spoken}`;
});
}
return result;
}
function splitProtectedSegments(text: string): string[] {
// インラインコードとURLは読み変換せず、そのまま保持する
return text.split(/(`[^`]*`|https?:\/\/[^\s]+)/g);
}
function isProtectedSegment(segment: string): boolean {
return segment.startsWith("`") || /^https?:\/\//.test(segment);
}
function findUnknownTerms(
text: string,
entries: GlossaryEntry[]
): string[] {
const registered = new Set(
entries.map((entry) => entry.surface.toLowerCase())
);
const found = new Set<string>();
for (const segment of splitProtectedSegments(text)) {
if (isProtectedSegment(segment)) continue;
const candidates = segment.match(
/\b(?:[A-Z]{2,}[A-Z0-9]*|[A-Z][A-Za-z]+[A-Z][A-Za-z0-9]*)\b/g
) ?? [];
for (const term of candidates) {
if (!registered.has(term.toLowerCase())) found.add(term);
}
}
return [...found];
}
export function normalizeForSpeech(
displayText: string,
glossary: Glossary
): NormalizedSpeech {
const applied = new Set<string>();
const speechText = splitProtectedSegments(displayText)
.map((segment) => {
if (isProtectedSegment(segment)) return segment;
return replaceInProse(
segment,
displayText,
glossary.entries,
applied
);
})
.join("");
return {
displayText,
speechText,
glossaryVersion: glossary.version,
appliedTerms: [...applied],
unknownTerms: findUnknownTerms(displayText, glossary.entries)
};
}
重要なのは、displayTextを上書きしないことです。画面にはLLMの原文を表示し、TTSにだけspeechTextを渡します。これにより、コピーしたテキストがカタカナ表記へ変わる問題を防げます。
また、コード断片とURLは変換対象から外しています。音声UIでURLをどう読むかは、通常の専門用語とは別のポリシーが必要です。
手順3:未登録語を会話から切り離して集計する
未登録語を見つけても、その場でLLMへ問い合わせません。語と出現回数だけを集計します。
src/candidate-store.tsを作成します。
import { appendFile } from "node:fs/promises";
export type UnknownTermEvent = {
term: string;
domain: string;
sessionIdHash: string;
glossaryVersion: number;
detectedAt: string;
};
export async function recordUnknownTerms(
terms: string[],
base: Omit<UnknownTermEvent, "term" | "detectedAt">
): Promise<void> {
for (const term of terms) {
const event: UnknownTermEvent = {
...base,
term,
detectedAt: new Date().toISOString()
};
await appendFile(
"data/unknown-terms.jsonl",
`${JSON.stringify(event)}\n`,
"utf8"
);
}
}
会話全文を保存しない点も重要です。発音候補の作成に必要なのが用語と対象分野だけなら、ユーザーの発言をそのままログへ残す必要はありません。
ただし、同音異義語などで文脈が不可欠な場合は、利用目的、保存期間、閲覧権限、ユーザーへの説明を先に決めてください。
手順4:LLMには読み方の「候補」だけを作らせる
OpenAI互換のLLMエンドポイントへ接続する最小例です。これは会話中ではなく、管理用バッチやレビュー画面から実行します。
src/pronunciation-proposer.tsを作成します。
import { randomUUID } from "node:crypto";
export type PronunciationProposal = {
surface: string;
candidates: string[];
reason: string;
needsHumanReview: true;
};
export async function proposePronunciation(
term: string,
domain: string,
signal?: AbortSignal
): Promise<{
requestId: string;
proposal: PronunciationProposal;
}> {
const baseUrl = process.env.LLM_BASE_URL;
const apiKey = process.env.LLM_API_KEY;
const model = process.env.LLM_MODEL;
if (!baseUrl || !apiKey || !model) {
throw new Error("LLM_BASE_URL、LLM_API_KEY、LLM_MODELが必要です");
}
const requestId = randomUUID();
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
"authorization": `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
temperature: 0,
messages: [
{
role: "system",
content: [
"あなたは日本語音声UIの発音辞書候補を作成します。",
"候補は最大3件にしてください。",
"候補を正解として確定しないでください。",
"JSON以外を出力しないでください。"
].join("\n")
},
{
role: "user",
content: JSON.stringify({
surface: term,
domain,
outputSchema: {
surface: "string",
candidates: ["string"],
reason: "string",
needsHumanReview: true
}
})
}
]
})
});
if (!response.ok) {
throw new Error(`LLM request failed: ${response.status}`);
}
const body = await response.json() as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = body.choices?.[0]?.message?.content;
if (!content) throw new Error("LLM response has no content");
const parsed = JSON.parse(content) as PronunciationProposal;
if (
parsed.surface !== term ||
!Array.isArray(parsed.candidates) ||
parsed.candidates.length === 0 ||
parsed.candidates.length > 3 ||
parsed.needsHumanReview !== true
) {
throw new Error("Invalid pronunciation proposal");
}
return { requestId, proposal: parsed };
}
requestIdはアプリケーション側のログへ保存し、どの候補生成がどのレビューにつながったか追跡します。接続先が任意のHTTPヘッダーを受け付けるとは仮定せず、まずローカルの監査情報として扱います。
LLMの候補は次の状態で管理すると安全です。
observed → proposed → approved → published
└→ rejected
proposedをそのまま本番辞書へ入れてはいけません。モデルを変更すると候補が変わる可能性があり、同じモデルでも固有名詞の正式な読みを保証できないためです。
手順5:割り込み後の古い読み上げを無効にする
発音変換がローカル処理でも、LLM応答やTTS処理は非同期です。ユーザーが割り込んだら、前ターンの音声を再生しないよう世代を切り替えます。
src/voice-turn.tsを作成します。
import type { Glossary } from "./normalizer.js";
import { normalizeForSpeech } from "./normalizer.js";
import { recordUnknownTerms } from "./candidate-store.js";
export type VoiceDependencies = {
generateReply: (
userText: string,
signal: AbortSignal
) => Promise<string>;
synthesizeAndPublish: (
speechText: string,
signal: AbortSignal
) => Promise<void>;
};
export class VoiceTurnController {
private generation = 0;
private active?: AbortController;
constructor(
private readonly glossary: Glossary,
private readonly dependencies: VoiceDependencies
) {}
interrupt(): void {
this.generation += 1;
this.active?.abort();
this.active = undefined;
}
async respond(params: {
userText: string;
domain: string;
sessionIdHash: string;
}): Promise<void> {
this.interrupt();
const generation = this.generation;
const controller = new AbortController();
this.active = controller;
const displayText = await this.dependencies.generateReply(
params.userText,
controller.signal
);
if (generation !== this.generation || controller.signal.aborted) return;
const normalized = normalizeForSpeech(displayText, this.glossary);
// 未登録語の記録失敗で会話を止めない
void recordUnknownTerms(normalized.unknownTerms, {
domain: params.domain,
sessionIdHash: params.sessionIdHash,
glossaryVersion: normalized.glossaryVersion
}).catch((error) => {
console.error("failed to record unknown term", error);
});
if (generation !== this.generation || controller.signal.aborted) return;
await this.dependencies.synthesizeAndPublish(
normalized.speechText,
controller.signal
);
}
}
synthesizeAndPublishは、利用するTTSとRTC連携に合わせて実装するアダプターです。ここでは未確認の製品API名を作らず、アプリケーション境界だけを定義しています。
TTS実装がAbortSignalによる中断に対応しない場合でも、次のチャンクを送らないことと、RTC側へ古い音声を公開しないことはアプリケーションで制御します。
確認方法:発音の主観評価だけで終わらせない
まず辞書変換を自動テストします。
test/normalizer.test.tsを作成します。
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeForSpeech, type Glossary } from "../src/normalizer.js";
const glossary: Glossary = {
version: 3,
entries: [
{
surface: "WebRTC",
spoken: "ウェブアールティーシー",
policy: "always",
contextHints: [],
approvedBy: "voice-editor",
approvedAt: "2026-08-01T00:00:00Z"
},
{
surface: "SQL",
spoken: "エスキューエル",
policy: "context",
contextHints: ["データベース", "クエリ"],
approvedBy: "voice-editor",
approvedAt: "2026-08-01T00:00:00Z"
}
]
};
test("表示文を変更せず、TTS用テキストだけを変換する", () => {
const result = normalizeForSpeech(
"WebRTCで音声を接続します。",
glossary
);
assert.equal(result.displayText, "WebRTCで音声を接続します。");
assert.equal(
result.speechText,
"ウェブアールティーシーで音声を接続します。"
);
assert.deepEqual(result.appliedTerms, ["WebRTC"]);
});
test("文脈条件を満たす場合だけ変換する", () => {
const matched = normalizeForSpeech(
"SQLクエリを確認します。",
glossary
);
assert.equal(
matched.speechText,
"エスキューエルクエリを確認します。"
);
const unmatched = normalizeForSpeech(
"SQLについて説明します。",
glossary
);
assert.equal(unmatched.speechText, "SQLについて説明します。");
});
test("コードとURLは変換しない", () => {
const result = normalizeForSpeech(
"`WebRTC`とhttps://example.com/WebRTCを確認してください。",
glossary
);
assert.equal(result.speechText, result.displayText);
});
test("未登録の英字語を候補として返す", () => {
const result = normalizeForSpeech(
"XQ9をAcmeCloudへ接続します。",
glossary
);
assert.deepEqual(result.unknownTerms.sort(), ["AcmeCloud", "XQ9"]);
});
実行します。
npm test
次に、音声システム全体では以下を確認します。
機能チェック
-
画面には
displayTextが表示される -
TTSには
speechTextだけが渡る - 未登録語が現在の発話で勝手にカタカナ化されない
- コード、URL、メールアドレスを別ポリシーで扱える
- 辞書の長い語が短い語より先に適用される
- 辞書バージョンを発話ログへ残せる
リアルタイム会話チェック
- LLM応答待ちにユーザーが話したら前ターンを中止できる
- TTS開始直前の割り込みでも古い音声を公開しない
- 未登録語ログの書き込み失敗で会話が止まらない
- LLM候補生成サービスが停止しても通常会話を継続できる
計測項目
普遍的な目標値を置くのではなく、端末とネットワーク条件ごとに次を測ります。
{
"turnId": "local-correlation-id",
"glossaryVersion": 3,
"normalizationMs": 1.8,
"appliedTermCount": 2,
"unknownTermCount": 1,
"interrupted": false
}
確認するのは平均値だけではありません。
- 発音正規化時間の中央値と上位パーセンタイル
- 1ターンあたりの未登録語数
- 辞書更新後に変化した発話数
- 割り込み後に古いTTS処理が残った件数
- LLM候補の承認、修正、却下の割合
候補の却下が多い場合、より大きなモデルへ替える前に、対象分野、略語の展開形、組織内の読み方ルールが不足していないか確認します。
辞書更新の判断フレームワーク
未登録語をすべて辞書へ入れると、辞書がノイズで膨らみます。次の順序で判断します。
-
繰り返し出現するか
一度しか現れないランダム文字列は登録しない -
読み間違いが理解を妨げるか
音が多少違っても意味が通じる語は優先度を下げる -
正式な読み方を決める責任者がいるか
ブランド名や社内用語は担当者へ確認する -
文脈で読みが変わるか
変わるならalwaysではなく条件付きにする -
テキスト置換で十分か
アクセントやイントネーションが問題なら、辞書置換ではなくTTS側の対応範囲を確認する
この最後の切り分けは重要です。カタカナへの置換で直せるのは主に読みの選択です。アクセント、速度、感情表現まで同じ仕組みで直そうとすると、辞書が発音制御言語の代用品になってしまいます。
注意点
1. LLMの候補を「正解率」で自動採用しない
検証用語集で良い結果が出ても、未知の製品名や人名に同じ品質が続くとは限りません。評価セットへの適合と、本番で自動採用してよいかは別の判断です。
2. ストリーミング中の単語分割に注意する
LLMのストリーミング断片がWebとRTCに分かれると、断片ごとの置換では辞書に一致しません。TTSへ送る単位は、句読点などで確定した短い節にするか、英数字トークンが閉じるまで小さくバッファします。
バッファを大きくすると発話開始が遅れ、小さくすると用語を分割しやすくなります。実際のログで両者を測り、端末や利用場面に合わせて決めます。
3. 辞書の更新を即時反映しない
進行中のセッションで辞書バージョンが変わると、同じ用語の読み方が会話途中で変化します。セッション開始時またはターン開始時に辞書スナップショットを固定すると、再現しやすくなります。
4. コンパニオン用途ではユーザー制御を残す
AIコンパニオンやキャラクター対話は、Tencent RTCのSocial Entertainmentシナリオでも扱われています。
音声キャラクターであっても、AIであることの表示、ミュート、停止、履歴削除、通報などのユーザー操作を発音改善より先に設計してください。発音が自然になるほど、ユーザーが人間の発話と誤認しない表示も重要になります。
まとめ
リアルタイムAI音声の発音改善で、LLMを使うか辞書を使うかの二択にする必要はありません。
- 会話経路は、承認済み辞書による決定的な変換にする
- LLMは、未登録語の候補生成へ非同期に使う
- 表示文と読み上げ文を分離する
- 割り込み時は古いLLM・TTS処理を無効にする
- 人間は、組織として採用する読み方と停止条件を決める
LLMが改善できるのは候補探索の速度です。どの読み方をユーザーへ聞かせるか、その変更をいつ公開するか、誤りをどう戻すかは、引き続き人間とアプリケーション設計の仕事です。
関係性の開示: 筆者はTencent RTCに関連するコンテンツ制作に関与しています。本稿の実装方針を確認する際、Tencent RTCの公式ドキュメントを参照しました。