要旨
音声ユーザー インターフェイスは、人間とコンピューターの対話の設計空間において独自の位置を占めています。音声ユーザー インターフェイスは、人間の会話の期待という容赦ない時計に反してリアルタイムで動作する必要があり、数百ミリ秒単位で測定される遅延により、認識が「応答性の高いアシスタント」から「壊れたシステム」に変化します。エンジニアリングの中心的な課題は、音声を生成することではなく、最新の TTS システムは人間品質の音声を生成します。しかし、単一の連続した会話の錯覚を維持しながら、言語モデルのトークン生成から文の分割、TTS 合成、音声再生、バージイン処理までのパイプラインを調整することです。
この論文では、Web Speech API 認識、Gemini 2.0 Flash 言語生成、リアルタイムの文境界検出、イレブンラボ TTS 合成、およびシーケンシャルオーディオ再生を含むパイプラインを通じて音声インタラクションを処理するプロダクション システムである MARIA OS に実装された文レベルのストリーミング VUI アーキテクチャについて説明します。 (単語レベルまたは完全な応答バッファリングとは対照的に) 文レベルの粒度に対する認知的正当性を形式化し、パイプライン レイテンシー モデルを導き出し、useGeminiLive フックの完全な実装を提示します。これは、マイク入力、AudioContext メーターリング、SpeechRecognition イベント処理、HTTP ストリーミング、TTS プロミス チェーンを含む 12 の同時非同期サブシステムを調整する 693 行の React 状態管理です。アボート制御、音声デバウンス、ローリング会話要約、ハートビート監視、iOS オーディオロック解除シーケンス。
コアの音声パイプラインを超えて、アクション ルーターについて説明します。アクション ルーターは、最近の通話履歴からの信頼度に重み付けされたチーム推論を使用して、4 つのチーム (秘書、営業、文書、開発) に編成された 29 のツール間で Gemini 関数呼び出しをルーティングするディスパッチ レイヤーです。ルーターは、音声コマンドからの UI 状態の変更を促す機能応答 (会話の継続のために Gemini にフィードバック) とクライアント命令 (navigate、notify、update_panel、open_modal) の両方を生成します。
2,400 を超える音声セッションから本番環境のメトリクスをレポートします。つまり、最初の文の遅延が 800 ミリ秒未満、文の順序違反がゼロ、ローリング サマリーによる 45 分以上の無限セッション、検出された 9 つのアプリ内ブラウザ環境における正常な低下です。このアーキテクチャは、文が音声インターフェイスのストリーミングの正しい単位であること、つまり人間の音声認識の認知チャンク サイズと一致すると同時に、高品質の TTS 韻律に十分なコンテキストを提供することを示しています。
1. 音声インターフェイスにおける遅延と自然さのトレードオフ
すべての音声インターフェイスは、2 つの極端なスペクトルの間のどこに自分自身を配置するかを選択する必要があります。一方では、トークン レベルのストリーミング により、生成された各トークンがすぐに TTS に送信され、最初の音声が聞こえるまでの時間が最小限に抑えられますが、韻律が不十分で途切れ途切れで不自然な音声が生成されます。TTS エンジンは文節全体を確認せずにイントネーションを計画できません。もう一方の完全応答バッファリングは、音声を合成する前に完全な LLM 応答を待ち、自然な音声を生成しますが、会話の交代の規範に違反する数秒の遅延が発生します。
1.1 会話のタイミングの予想
ターンテイキングに関する心理言語学的研究では、人間はターン完了後 200 ~ 700 ミリ秒以内に反応が始まると予想していることが証明されています。 1000 ミリ秒を超える遅延は、否定的な社会的推論を引き起こします。聞き手は、話し手が不確かで、無関心で、または機能不全に陥っていると認識します。人間とコンピューターの対話では、このしきい値はさらに厳しいものになります。ユーザーは遅延が認知処理ではなくシステム障害によるものであると考えます。
ここで、 t_debounce は音声無音バッファー (ユーザーが話し終えたことを確認する)、 t_LLM_first_sentence は言語モデルが最初の文の境界を通過するトークンを生成する時間、 t_TTS_Synthetic は最初の文の イレブンラボ API ラウンドトリップ、 t_audio_decode はブラウザーのオーディオ デコードと再生の開始時間です。
1.2 韻律遅延のフロンティア
TTS の品質は、合成モデルで利用できる先読みコンテキストの量に大きく依存します。単一の単語を入力すると、単調なロボットのような出力が生成されます。フルパラグラフ入力により、自然な韻律計画 (強調、リズム、ピッチ輪郭) が可能になりますが、最初のオーディオは生成時間全体に遅れます。文は、TTS エンジンが自然な韻律を実現する最小単位を表します。文には、モデルがイントネーションの輪郭、強勢パターン、および句の境界を計画するための十分な構文構造が含まれています。
この結果は、ストリーミング トークン出力で文の境界を検出し、完全な各文を個別に TTS にディスパッチするという、MARIA OS の音声パイプラインの中核における設計上の決定を正当化します。
2. 文レベルのストリーミング: 認知的正当化とアーキテクチャ
2.1 認知の塊としての文
認知心理学では、文章は作業記憶における言語処理の主要な単位として認識されます。 Miller のチャンキング理論 (1956) は、作業記憶が個々の要素ではなく構造化されたチャンクで機能することを確立しました。音声認識では、文 (または文節) が基本的な部分として機能します。聞き手は入ってくる音声を段階的に処理しますが、文の境界では解釈に専念します。これにより、聞き手が次の文に注目する前に現在の文を談話文脈に統合する自然な「処理チェックポイント」が作成されます。
文の粒度でストリーミングする音声インターフェイスは、この認知アーキテクチャと一致しています。各文は、リスナーがすぐに処理できる完全な意味単位として到着し、同時に次の文が生成および合成されます。リスナーは、(単語レベルのストリーミングの場合のように) 部分的な考えや、(フルレスポンスのバッファリングの場合のように) 圧倒的な独り言を受け取ることはありません。
2.2 文境界の検出
文境界検出器は、LLM から段階的に到着する UTF-8 トークンのバイト ストリームを操作する必要があります。 MARIA OS はバイリンガルであるため、英語と日本語の句読点の両方を処理する必要があります。検出正規表現は次のとおりです。
// Extract complete sentences (Japanese and English punctuation)
const sentenceEnd = /[\u3002.!?\uff01\uff1f\n]/
let match = sentenceEnd.exec(buffer)
while (match) {
const idx = match.index + 1
const sentence = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx)
if (sentence) {
fullText += (fullText ? " " : "") + sentence
// Fire TTS immediately for this sentence
enqueueTTS(sentence, signal)
}
match = sentenceEnd.exec(buffer)
}検出器は 6 つの境界文字を認識します:「。」 (日本語のピリオド)、「.」 (英語のピリオド)、「!」と「?」 (英語の感嘆符/疑問符)、「!」と「?」(全角の日本語の感嘆符/疑問符)、および「\n」 (改行、改行を文の境界として扱う)。これにより、完全な NLP パーサーを必要とせずに、両方の言語の文末句読点の大部分がカバーされます。
2.3 シーケンシャル TTS プロミス チェーン
TTS サブシステムの重要な不変条件は 文の順序付け です。TTS 合成は非同期であり、個々の文の長さが異なる場合でも、文は生成された正確な順序で再生されなければなりません。 MARIA OS は、シーケンシャル プロミス チェーン を通じてこれを強制します。このパターンでは、各 TTS タスクが共有プロミス チェーンに追加され、明示的なキュー データ構造なしで FIFO の実行が保証されます。
// Enqueue a sentence for sequential TTS playback
const enqueueTTS = (text: string, signal?: AbortSignal) => {
ttsChainRef.current = ttsChainRef.current.then(() => {
if (signal?.aborted) return
return playElevenLabsTTS(text, signal)
})
}この 3 行の機能は、音声パイプライン全体のアーキテクチャの要となります。 ttsChainRef は、再生キューの末尾を表す 1 つの Promise<void> を保持します。 「enqueueTTS」を呼び出すたびに、チェーンが 1 つのリンクだけ拡張されます。新しい文は、前の文の Promise が解決された後 (つまり、音声が終了した後) にのみ再生を開始します。 「signal」パラメータはチェーン全体を通して「AbortController」をスレッド化し、バージイン時の即時キャンセルを可能にします。
3. 全二重会話エンジンの設計
3.1 バージインの問題
全二重音声対話では、システムが音声を再生している間にユーザーが話す場合に対処する必要があります。電話では、これは バージイン と呼ばれます。つまり、ユーザーがシステムの出力に割り込むことです。バージインでは、次の 2 つの要件が同時に発生します。(1) 現在の TTS 再生を直ちに停止し、保留中の文があればキャンセルします。(2) システム自体のオーディオ出力がマイクによってキャプチャされ、ユーザーの音声 (エコー フィードバック) として解釈されるのを防ぎます。
MARIA OS は、認識の一時停止、中断制御の再生、TTS 後の回復遅延という 3 層の戦略を通じてバージインを処理します。
// Pause SpeechRecognition to prevent echo feedback during TTS
const pauseRecognition = () => {
if (recognitionRef.current) {
try { recognitionRef.current.stop() } catch { /* already stopped */ }
}
}
// Resume SpeechRecognition after TTS finishes
const resumeRecognition = () => {
if (statusRef.current === "connected" && recognitionRef.current) {
try { recognitionRef.current.start() } catch { /* already running */ }
}
}一時停止/再開パターンは一見単純ですが、フィードバック ループを防ぐために重要です。 processUserSpeech が呼び出されると、TTS が開始される直前に認識が一時停止されます。 TTS チェーン全体が完了した後、500 ミリ秒の遅延により、認識が再開される前に残留エコーが消散します。この遅延がなければ、TTS オーディオの最後の音節がマイクによってキャプチャされ、ユーザー入力として再処理され、無限のエコー ループが作成されます。
3.2 音声のデバウンス
SpeechRecognition は、完全な発話ではなく、認識されたフレーズの粒度で「結果」イベントを発行します。ユーザーが「明日の午後 3 時に田中さんとの会議をスケジュールしてください」と言うと、「会議をスケジュールしてください」、「田中さんとの」、「明日の午後 3 時に」という 3 つの個別の最終結果が生成される可能性があります。デバウンスがなければ、各フラグメントは独立した Gemini リクエストをトリガーし、3 つの個別の応答を生成します。
MARIA OS は音声の断片をバッファに蓄積し、完全な発話を送信する前に 1.2 秒間の沈黙を待ちます。
const SPEECH_DEBOUNCE_MS = 1200 // wait 1.2s of silence after last final result
recognition.onresult = (event: any) => {
const last = event.results[event.results.length - 1]
if (!last.isFinal) return
const text = last[0].transcript.trim()
if (!text) return
// Accumulate text and debounce
speechBufferRef.current += (speechBufferRef.current ? " " : "") + text
if (speechDebounceRef.current) clearTimeout(speechDebounceRef.current)
speechDebounceRef.current = setTimeout(() => {
const accumulated = speechBufferRef.current.trim()
speechBufferRef.current = ""
speechDebounceRef.current = null
if (accumulated) processUserSpeech(accumulated)
}, SPEECH_DEBOUNCE_MS)
}1200 ミリ秒のデバウンス値は経験的に決定されました。 800 ミリ秒未満の値では、複数の文節を含む日本語の発話が断片化します (文節間の休止は自然に 600 ~ 700 ミリ秒に達します)。 1500 ミリ秒を超える値では、ユーザーが「システムがしばらく私を無視する」と報告する知覚可能な遅延が発生しました。 1200 ミリ秒の値は、英語と日本語の両方の音声パターンにわたって完全性と応答性のバランスをとります。
3.3 ロックとガードの加工
isProcessingRef フラグは、Gemini リクエストの同時実行を防ぎます。 processUserSpeech が開始されると、フラグが true に設定され、開始時にチェックされます。
const processUserSpeech = async (text: string) => {
// If already processing, ignore — don't barge-in with echo
if (isProcessingRef.current) return
isProcessingRef.current = true
processingStartRef.current = Date.now()
// Pause recognition to prevent TTS echo
pauseRecognition()
// ... Gemini stream + TTS ...
// ALWAYS clear processing flag — prevents permanent lockout
isProcessingRef.current = false
// Resume recognition after TTS echo dissipates
setTimeout(resumeRecognition, 500)
}「processingStartRef」タイムスタンプにより、ハートビート モニターがスタックした処理状態を検出できるようになります。このフラグは「finally」ブロックで無条件にクリアされ、ストリーミングまたは TTS サブシステムでの未処理の例外による永続的なロックアウトを防ぎます。
4. ストリーミング パイプライン: トークンからオーディオへ
4.1 パイプラインのアーキテクチャ
ユーザーの音声からシステムの音声応答までの完全なパイプラインは、次の 7 つの段階を通過します。
| Stage | Component | Latency Contribution | Description |
|---|---|---|---|
| 1. Capture | Web Speech API | ~50ms | Browser speech-to-text (continuous mode) |
| 2. Debounce | speechBufferRef | 1200ms (fixed) | Accumulate fragments, wait for silence |
| 3. Generate | Gemini 2.0 Flash | ~200-400ms TTFS | Server-streamed response via ReadableStream |
| 4. Detect | sentenceEnd regex | <1ms | Sentence boundary detection on byte buffer |
| 5. Synthesize | ElevenLabs API | ~150-300ms | eleven_turbo_v2_5, mp3_22050_32 |
| 6. Decode | HTMLAudioElement | ~20ms | Browser MP3 decode + audio output |
| 7. Chain | ttsChainRef | 0ms (pipelined) | Sequential promise ensures FIFO order |
最初の文の知覚される合計遅延は、ステージ 2 と 3 によって支配されます。つまり、デバウンス遅延 (1200 ミリ秒に固定) に、Gemini が最初の文の境界を通過してトークンを生成する時間 (通常、15 ~ 20 のトークン センテンスの場合は 200 ~ 400 ミリ秒) を加えたものです。ステージ 5 と 6 では、最初のセンテンスに 170 ~ 320 ミリ秒が追加されますが、後続のセンテンスは完全にパイプライン処理されます。センテンス N が再生されている間、センテンス N+1 はすでに合成されています。
4.2 ストリーミングパーサーの実装
コア ストリーミング ループは、Gemini 応答本文から ReadableStream として読み取り、バッファーにバイトを蓄積し、チャンクが到着するたびに文の境界をスキャンします。
const reader = chatRes.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
let fullText = ""
// Reset TTS chain
ttsChainRef.current = Promise.resolve()
while (true) {
const { done, value } = await reader.read()
if (done) break
if (signal.aborted) break
buffer += decoder.decode(value, { stream: true })
// Extract complete sentences
const sentenceEnd = /[\u3002.!?\uff01\uff1f\n]/
let match = sentenceEnd.exec(buffer)
while (match) {
const idx = match.index + 1
const sentence = buffer.slice(0, idx).trim()
buffer = buffer.slice(idx)
if (sentence) {
fullText += (fullText ? " " : "") + sentence
enqueueTTS(sentence, signal)
}
match = sentenceEnd.exec(buffer)
}
}
// Handle remaining text (no punctuation at end)
if (!signal.aborted && buffer.trim()) {
fullText += (fullText ? " " : "") + buffer.trim()
enqueueTTS(buffer.trim(), signal)
}TextDecoder の { stream: true } オプションは必須です。このオプションは、チャンク境界を越えて分割される可能性のあるマルチバイト UTF-8 文字 (日本語では一般的) を処理します。このフラグがないと、「。」のような文字 (UTF-8 では 3 バイト) が 2 つのチャンクに分割され、デコード エラーが発生する可能性があります。
4.3 アクションストリームの解析
アクション ルーターが有効になっている場合、Gemini 応答ストリームには、自然言語応答の前にアクション メタデータをエンコードする構造化プレフィックスが含まれます。
// Parse __ACTIONS__ prefix from action-chat stream
if (!actionsParsed && buffer.includes("__ACTIONS__")) {
const actionEnd = buffer.indexOf("__END_ACTIONS__")
if (actionEnd !== -1) {
const actionJson = buffer.slice(
buffer.indexOf("__ACTIONS__") + 11,
actionEnd,
)
try {
const meta = JSON.parse(actionJson) as ActionMeta
if (meta.actions?.length > 0) {
onClientInstructions?.(meta.actions, meta.teamContext)
}
} catch { /* JSON parse failed — ignore */ }
buffer = buffer.slice(actionEnd + 15)
actionsParsed = true
} else {
continue // wait for __END_ACTIONS__
}
}このインバンド シグナリング アプローチでは、別個の WebSocket チャネルを必要とせずに、構造化データをテキスト ストリームに埋め込みます。 __ACTIONS__...__END_ACTIONS__ 区切り文字は、自然言語では非常にありそうにないように選択されており、パーサーは JSON 解析を試行する前に完全な区切り文字のペアを待ち、部分的なチャンクの配信を適切に処理します。
5. アクションルーター: 音声主導のマルチチームツールオーケストレーション
5.1 ルーターのアーキテクチャ
Action Router は、Gemini 関数呼び出しを 4 つのチームにまたがって編成されたハンドラー関数にマップするシングルトン ディスパッチャーです。ルーターは Gemini から関数呼び出し (名前 + 引数) を受け取り、登録されたハンドラーを見つけて実行し、関数応答 (会話の継続のために Gemini にフィードバックされる) とクライアント命令 (UI 更新用) の両方を返します。
class ActionRouter {
private registry = new Map<string, ActionDefinition>()
register(definition: ActionDefinition): void {
this.registry.set(definition.name, definition)
}
async dispatch(call: GeminiFunctionCall): Promise<ActionResult> {
const definition = this.registry.get(call.name)
if (!definition) {
return {
functionResponse: `Error: Unknown action "${call.name}"`,
clientInstructions: [
{ type: "notify", message: `Unknown action: ${call.name}`, severity: "error" },
],
}
}
try {
return await definition.handler(call.args)
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"
return {
functionResponse: `Error executing ${call.name}: ${message}`,
clientInstructions: [
{ type: "notify", message: `Action failed: ${message}`, severity: "error" },
],
}
}
}
}レジストリは、階層的なチームベースの構造ではなく、フラットな Map<string, ActionDefinition> を使用します。これにより、チーム数に関係なくディスパッチ時間が O(1) になり、ツールを重複することなくチーム間で共有できるようになります。
5.2 チーム編成とツールの配布
29 の登録ツールは 4 つのチームと 3 つの共有ツールに分散されています。
| Team | Tools | Scope | Examples |
|---|---|---|---|
| Shared | 3 | public | navigate_dashboard, get_system_status, search_knowledge |
| Secretary | 10 | member | get_calendar, create_calendar_event, search_emails, send_email, get_attendance |
| Sales | 10 | member | calculate_estimate, create_invoice_draft, get_deals, draft_proposal, get_monthly_revenue |
| Document | 8 | member | create_report, create_spreadsheet, create_presentation, generate_meeting_notes |
| Dev | 8 | member | code_consult, architecture_review, tech_debt_assess, sprint_plan, debug_assist |
5.3 信頼度加重チーム推論
チーム推論システムは、最近の関数呼び出し履歴に基づいて、どのチームが状況に応じてアクティブであるかを判断します。これにより、ユーザーを明示的に切り替えることなく、システムがそのペルソナと利用可能なツールの強調を適応させることができます。このアルゴリズムでは、最新性を重み付けしたカウントを使用します。
export function inferTeam(
recentFunctionCalls: string[],
): { teamId: TeamId; confidence: number } {
if (recentFunctionCalls.length === 0) {
return { teamId: "secretary", confidence: 0 }
}
const teamCounts: Record<TeamId, number> = {
secretary: 0, sales: 0, document: 0, dev: 0,
}
for (let i = 0; i < recentFunctionCalls.length; i++) {
const teamId = TOOL_TO_TEAM[recentFunctionCalls[i]]
if (teamId) {
// Most recent call gets 3x weight
const weight = i === recentFunctionCalls.length - 1 ? 3 : 1
teamCounts[teamId] += weight
}
}
const entries = Object.entries(teamCounts) as [TeamId, number][]
entries.sort((a, b) => b[1] - a[1])
const [topTeam, topCount] = entries[0]
const totalWeight = entries.reduce((sum, [, c]) => sum + c, 0)
return {
teamId: topTeam,
confidence: totalWeight > 0 ? topCount / totalWeight : 0,
}
}最新の通話の 3 倍の重みにより、強い最新性バイアスが生じます。たとえ、前の 3 回の通話がすべて営業であったとしても、秘書ツールの 1 回の通話で、推定されるチームがすぐに変更されます。信頼度スコア (総ウェイトに対する上位チームのウェイトの比率) は、UI レイヤーへの信号として機能します。低い信頼度 (0.5 未満) はユーザーがドメイン間を切り替えていることを示し、高い信頼度 (0.8 以上) は単一ドメインのインタラクションが継続していることを示します。
5.4 クライアント指示型システム
Action Router は、フロントエンドが解釈して UI 状態を変更するクライアント命令の区別された結合を出力します。
export type ClientInstruction =
| { type: "navigate"; path: string }
| { type: "notify"; message: string; severity: ActionSeverity }
| { type: "update_panel"; panel: string; data: Record<string, unknown> }
| { type: "open_modal"; modal: string; props: Record<string, unknown> }
| { type: "noop" }この設計により、音声システムのセマンティック理解 (「ユーザーはカレンダーを見たい」) が UI システムのレンダリング ロジック (「/dashboard/calendar に移動する」) から分離されます。音声パイプラインは抽象的な命令を生成します。フロントエンド命令実行プログラムは、それらを具体的な DOM 操作にマップします。
6. ローリングサマリーによる無限セッション管理
6.1 コンテキストウィンドウの問題
LLM コンテキスト ウィンドウは有限です。 30 分以上実行される音声セッションでは、簡単に 50 ~ 100 の会話ターンが蓄積され、有効なコンテキスト ウィンドウを超え、モデルが初期の会話コンテキストを失ったり、応答品質が低下したりする可能性があります。古いメッセージを単純に切り詰めると、ユーザーの最初の指示、確立された設定、未解決のトピックなど、潜在的に重要なコンテキストが破棄されます。
6.2 ローリングサマリーアーキテクチャ
MARIA OS は、最近のメッセージをそのまま保持しながら、古い会話履歴を圧縮した要約に圧縮する ローリング サマリー メカニズムを実装しています。このアルゴリズムは、会話の長さがしきい値を超えるとトリガーされます。
const SUMMARY_THRESHOLD = 16
const KEEP_RECENT = 6
if (conversationRef.current.length > SUMMARY_THRESHOLD) {
const toSummarize = conversationRef.current.slice(0, -KEEP_RECENT)
const recent = conversationRef.current.slice(-KEEP_RECENT)
try {
const res = await fetch("/api/voice/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: toSummarize,
existingSummary: summaryRef.current,
}),
})
if (res.ok) {
const { summary } = await res.json()
summaryRef.current = summary
}
} catch { /* summarization failed — just trim */ }
// Reconstruct: summary context + recent messages
conversationRef.current = [
...(summaryRef.current
? [{ role: "user", text: `[Conversation summary: ${summaryRef.current}]` }]
: []),
...recent,
]
}6.3 概要構成
ローリング サマリーの重要な特性は 構成可能性 です。会話が再びしきい値を超えると、以前のサマリーとそれ以降に蓄積されたメッセージの両方から新しいサマリーが生成されます。 existingsummary パラメータは、繰り返しの要約によって会話の最初の部分の情報が失われないようにします。要約は置き換えられるのではなく蓄積されます。
要約頻度と API コストのバランスをとるために、16 メッセージ (6 メッセージは保持) というしきい値が選択されました。一般的な音声会話速度 (1 分あたり 2 ~ 4 ターン) では、要約は 4 ~ 8 分ごとにトリガーされます。これは、コンテキストのオーバーフローを防ぐのに十分な頻度ですが、要約 API 呼び出しによって会話が目に見えて遅延しない程度に十分な頻度ではありません。
7. レジリエンス エンジニアリング: ハートビート、フェールセーフ、リカバリ
7.1 音声パイプラインの障害モード
音声パイプラインは、通知なく失敗する可能性がある複数のブラウザ API に依存しているため、非常に脆弱です。エラー コードを返す HTTP リクエストとは異なり、SpeechRecognition や AudioContext などのブラウザ API は、エラー コールバックをトリガーせずに無効な状態になる可能性があります。実稼働環境における 3 つの重大な障害モードを特定しました。
- 処理のスタック: Gemini ストリームまたは TTS チェーン内の未処理の例外により、
isProcessingRefフラグが永続的に true になり、今後のすべてのユーザー入力が処理されなくなります。 - AudioContext の一時停止: iOS Safari は、ページがフォーカスを失ったとき (タブの切り替え、ロック画面)、AudioContext を一時停止し、フォーカスが戻ったときに自動的に再開しません。音声レベル メーターがゼロを示し、TTS オーディオが再生されますが聞こえません。
- SpeechRecognition の停止: SpeechRecognition は
onendを起動しますが、onend内の再起動の試行はサイレントにスローされ、認識は永久に停止したままになります。ユーザーが話しても何も起こらない
7.2 ハートビートモニター
ハートビートは 10 秒間隔で実行され、次の 3 つの障害モードすべてに対処します。
// Heartbeat keepalive (10s interval)
heartbeatRef.current = setInterval(() => {
if (statusRef.current !== "connected") return
// Force-clear processing flag if stuck for >30s
if (isProcessingRef.current &&
Date.now() - processingStartRef.current > 30_000) {
isProcessingRef.current = false
resumeRecognition()
}
// Resume suspended AudioContext
if (audioContextRef.current?.state === "suspended") {
audioContextRef.current.resume().catch(() => {})
}
}, 10_000)30 秒のスタック処理しきい値は実稼働データから導き出され、最長の正当な処理サイクル (複数の TTS センテンスを含む複雑なマルチツール アクション) は 25 秒未満で完了しました。 30 秒のしきい値は 5 秒の安全マージンを提供する一方で、本当にスタックしたセッションはしきい値を超えた後の 1 ハートビート サイクル以内に回復することを保証します。
7.3 音声認識の再開戦略
SpeechRecognition の「onend」ハンドラーは、システムが現在処理中かどうかに応じて、デュアルパス再起動戦略を実装します。
recognition.onend = () => {
if (statusRef.current !== "connected") return
if (!isProcessingRef.current) {
// Not processing — restart immediately
const delay = iosDevice ? 100 : 0
setTimeout(() => {
if (statusRef.current === "connected") {
try { recognition.start() } catch { /* already running */ }
}
}, delay)
} else {
// Processing (TTS playing) — set a failsafe timer.
// If resumeRecognition doesn't restart within 30s, force restart.
setTimeout(() => {
if (statusRef.current === "connected") {
isProcessingRef.current = false
try { recognition.start() } catch { /* already running */ }
}
}, 30_000)
}
}iOS 固有の再起動前の 100ms 遅延は、「onend」内で「recognition.start()」を同期的に呼び出すと「InvalidStateError」がスローされるという WebKit のバグに対処します。この遅延により、再起動を試みる前に内部ステート マシンが完全に「停止」に移行することが保証されます。
7.4 中止制御のクリーンアップ
すべての Gemini リクエストと TTS チェーンは、切断時または割り込み時の瞬時のクリーンアップを可能にする AbortController によって管理されます。
const interruptPlayback = () => {
// Abort in-flight Gemini stream + TTS fetches
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
// Stop audio immediately
if (elAudioRef.current) {
elAudioRef.current.pause()
elAudioRef.current.currentTime = 0
}
// Reset TTS chain
ttsChainRef.current = Promise.resolve()
}AbortController 信号は、Gemini への fetch() 呼び出し (HTTP ストリームをキャンセル)、ElementLabs への各 fetch() 呼び出し (TTS 合成をキャンセル)、およびそれぞれの playイレブンLabsTTS Promise (オーディオ再生の停止) の 3 つのレイヤーを介してスレッドされます。これにより、コントローラーを中止すると、実行中のすべてのネットワーク要求が即座に停止され、単一のイベント ループ ティック内ですべてのオーディオ出力が停止されます。
8. クロスプラットフォーム互換性: アプリ内ブラウザーの課題
8.1 アプリ内ブラウザの問題
最近のモバイル ユーザーは、ネイティブ ブラウザではなく埋め込み WebView コンテナで Web ページをレンダリングするメッセージング アプリ (LINE、Discord、Instagram) からのリンクを頻繁に開きます。これらのアプリ内ブラウザーは、特にオーディオ API と音声 API に関して、一貫性のない動作を持つ Web API のサブセットを提供します。 MARIA OS はこれらの環境を検出し、不可解な障害ではなく正常な機能低下を提供する必要があります。
8.2 検出アーキテクチャ
検出システムは 2 層のアプローチを使用します。名前付きアプリ内ブラウザーのユーザー エージェント パターン マッチングと、自身を識別しない iOS WKWebView コンテナーのヒューリスティック検出です。
const IN_APP_PATTERNS: [RegExp, InAppBrowser][] = [
[/\bLine\//i, "line"],
[/\bLIFF\b/i, "line"],
[/\bDiscord\b/i, "discord"],
[/\bInstagram\b/i, "instagram"],
[/\bFBAN\b/i, "facebook"],
[/\bFBAV\b/i, "facebook"],
[/\bFB_IAB\b/i, "facebook"],
[/\bMessenger\b/i, "messenger"],
[/\bTwitter\b/i, "twitter"],
[/\bMicroMessenger\b/i, "wechat"],
[/\bTikTok\b/i, "tiktok"],
[/\bSnapchat\b/i, "snapchat"],
]iOS の WKWebView 検出の場合、システムは「window.safari」が存在しないかどうかをチェックします。このプロパティは、実際の Safari には存在しますが、LINE、Discord、およびユーザー エージェントに識別文字列を追加しないその他のアプリで使用される WKWebView コンテナーには存在しません。
function detectIOSInAppWebView(): boolean {
if (typeof navigator === "undefined" || typeof window === "undefined") return false
const ua = navigator.userAgent
const isiOS = /iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
if (!isiOS) return false
const w = window as any
if (typeof w.safari === "undefined") return true
return false
}8.3 iOS 固有のオーディオ処理
iOS Safari (および iOS WebView) には、特別な処理を必要とする 2 つの制約が課されます。
- AudioContext サンプル レート: iOS ハードウェアは 44.1kHz または 48kHz で動作します。異なるサンプル レート (音声の場合は 16kHz など) を強制すると、出力が無音または歪みます。 MARIA OS は、sampleRate オプションなしで AudioContext を作成し、デバイスがネイティブ レートを選択できるようにします。
- オーディオ自動再生ポリシー: iOS では、ユーザーの直接のジェスチャによってオーディオ再生を開始する必要があります。 MARIA OS は、
connect()呼び出し (ボタン クリック ハンドラー) 中に永続的な<audio>要素を作成し、サイレント Base64 でエンコードされた MP3 スニペットを再生することでロックを解除します。
// iOS audio unlock — silent MP3 played during user gesture
if (iosDevice) {
if (!elAudioRef.current) {
elAudioRef.current = new Audio()
}
const audio = elAudioRef.current
audio.src = "data:audio/mpeg;base64,SUQzBAAA..." // silent MP3
audio.volume = 0.01
try {
await Promise.race([
audio.play(),
new Promise((r) => setTimeout(r, 1000)), // 1s max for unlock
])
audio.pause()
} catch { /* non-fatal */ }
audio.volume = 1
}1 秒のタイムアウトを持つ Promise.race は、play() が無期限にハングするデバイス上の接続フローがロック解除によってブロックされるのを防ぎます。音量が低い (0.01) ため、デバイスのスピーカーが最大音量であっても、サイレント MP3 はまったく聞こえなくなります。
8.4 音声認識連続モード
デスクトップ ブラウザと Android では、SpeechRecognition.continuous = true により、複数の発話にわたって認識セッションがアクティブに維持されます。 iOS では、連続モードは信頼性が低く、認識は 10 ~ 15 秒後に静かに停止します。 MARIA OS は iOS で「continuous = false」を設定し、「onend」ハンドラーに依存して各発話後に認識を再開します。
const recognition = new SR()
recognition.continuous = !iosDevice
recognition.interimResults = false
recognition.lang = locale === "ja" ? "ja-JP" : "en-US"8.5 プラットフォームのサポート マトリックス
| Platform | SpeechRecognition | AudioContext | TTS Playback | Mic Access | Status |
|---|---|---|---|---|---|
| Chrome (Desktop) | continuous | native rate | HTMLAudioElement | standard | Full support |
| Safari (macOS) | webkit prefix | native rate | HTMLAudioElement | standard | Full support |
| Safari (iOS) | webkit, non-continuous | native rate, must not force | silent MP3 unlock | standard | Full support (with workarounds) |
| Chrome (Android) | continuous | native rate | HTMLAudioElement | standard | Full support |
| LINE in-app | unavailable | varies | blocked | timeout | Redirect to native browser |
| Discord in-app | unavailable | varies | blocked | timeout | Redirect to native browser |
| Instagram in-app | partial | varies | blocked | blocked | Redirect to native browser |
9. 生産指標と評価
9.1 TTS 構成
MARIA OS は、低遅延ストリーミング用に最適化された イレブンラボの「eleven_turbo_v2_5」モデルを使用します。音声パラメータは自然な会話用に調整されています。
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`,
{
method: "POST",
headers: {
"xi-api-key": ELEVENLABS_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
model_id: "eleven_turbo_v2_5",
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
},
output_format: "mp3_22050_32",
}),
},
)パラメーターの選択には意図的なトレードオフが反映されています。stability 0.5 はピッチとリズムの表現力豊かな変化を可能にし (値が高いほど単調な出力が生成されます)、similarity_boost 0.75 は自然な変化を許容しながら音声の同一性を維持し、mp3_22050_32 (22.05kHz、32kbps) はダウンロード サイズに対する音質のバランスをとります。単一の文 (15 ~ 20 ワード) で約 8 ~ 12KB が生成されます。音声データの大容量化により、モバイルネットワークでも高速転送が可能になります。
9.2 レイテンシの内訳
500 の代表的なインタラクションにわたって測定:
| Metric | P50 | P90 | P99 |
|---|---|---|---|
| Speech debounce | 1200ms | 1200ms | 1200ms |
| Gemini TTFS (first sentence) | 280ms | 420ms | 680ms |
| ElevenLabs synthesis | 190ms | 310ms | 520ms |
| Audio decode + play start | 15ms | 25ms | 40ms |
| **Total first-sentence latency** | **1685ms** | **1955ms** | **2440ms** |
| Perceived latency (excl. debounce) | **485ms** | **755ms** | **1240ms** |
9.3 信頼性の指標
| Metric | Value | Measurement Period |
|---|---|---|
| Sessions without ordering violation | 2,400+ | 90-day production window |
| Mean session duration | 8.2 min | All sessions |
| Max session duration (rolling summary) | 47 min | Single session record |
| Heartbeat recovery events | 23 | Processing stuck >30s |
| AudioContext recovery events | 156 | iOS background/foreground |
| In-app browser redirects | 412 | Users guided to native browser |
9.4 音声レベルの測定
音声レベル メーターは、音声の振幅に反応する 3D OGL オーブを通じて、リアルタイムの視覚的なフィードバックを提供します。計測では、AnalyserNode を使用してマイク入力から RMS エネルギーを計算します。
const measureLevel = useCallback(() => {
if (!analyserRef.current || !dataArrayRef.current) return
analyserRef.current.getByteFrequencyData(dataArrayRef.current)
let sum = 0
for (let i = 0; i < dataArrayRef.current.length; i++) {
const v = dataArrayRef.current[i] / 255
sum += v * v
}
const rms = Math.sqrt(sum / dataArrayRef.current.length)
setVoiceLevel(Math.min(rms * 3.0, 1))
rafRef.current = requestAnimationFrame(measureLevel)
}, [])RMS 値の 3.0 倍の乗数は、全ダイナミック レンジに対して音声信号の典型的な低エネルギーを補償します。増幅を行わないと、会話音声は 0 ~ 1 スケールの 0.1 ~ 0.2 にすぎず、かろうじて見えるオーブ アニメーションが生成されます。 Math.min(..., 1) クランプは、大きな過渡現象 (咳や机への衝撃) による視覚的なアーティファクトを防ぎます。
10. 将来の拡張: 再帰的音声ガバナンスに向けて
10.1 接続ステートマシン
音声セッションのライフサイクルは 4 つの状態マシンに従います。
「接続」状態は、ハートビートが実行され、音声認識がアクティブで、TTS 再生が許可される唯一の状態です。 「error」状態では、診断目的で接続メタデータが保存され、後続の「connect()」呼び出しによる再試行が可能になります。
10.2 責任ゲート型音声アクションに向けて
現在のアクション ルーターは責任ゲートなしでツールをディスパッチします。登録されたすべてのアクションはすぐに実行可能です。自然な拡張として、MARIA OS 意思決定パイプラインを音声レイヤーに統合し、影響の大きい音声アクションに対して承認ゲートを必要とします。ユーザーが「15% 割引で修正提案を田中さんに送信してください」と言ったとします。現在、「draft_proposal」ツールはすぐに実行されます。責任ゲーティングを使用すると、システムは財務上の影響 (割引の承認) を検出し、パイプラインに意思決定記録を作成し、「15% 割引で提案書を作成しました。」と応答します。これには送信前にマネージャーの承認が必要です。承認のために転送しましょうか?』
10.3 マルチモーダル音声ガバナンス
今後の反復では、音声パイプラインを拡張して、音声対話中のマルチモーダル証拠収集をサポートする予定です。責任ゲートがトリガーされると、システムはユーザーに口頭での正当化を提供するよう促し、それが転写され、タイムスタンプが押され、証拠の束として決定記録に添付されます。これにより、音声コマンドから承認ゲート、実行までの完全に監査可能なチェーンが作成され、ユーザー自身の言葉が承認の証拠として機能します。
10.4 音声パイプラインの再帰的自己改善
ローリング サマリー メカニズムは、再帰的な音声インテリジェンスの基盤を提供します。システムはサマリー パターンを分析して、繰り返し発生するユーザーの意図を検出し、関連ツールを先制して読み込んで、ワークフローの最適化を提案します。カレンダーのチェックに続いて電子メールの下書きを一貫して要求するユーザーには、エンジニアリングの介入なしに既存のツールから動的に構成された、「スケジュールと通知」を組み合わせた音声ワークフローが提供される可能性があります。
参考文献
- ミラー、ジョージア州(1956年)。魔法の数字 7 プラスマイナス 2: 私たちの情報処理能力にはいくつかの制限があります。 心理学的レビュー、63(2)、81-97。
- サウスカロライナ州レビンソン (2016)。人間のコミュニケーションにおける交代: 言語処理の起源と影響。 認知科学の動向、20(1)、6-14。
- スティバース、T.ら。 (2009年)。会話の交代における普遍性と文化的多様性。 PNAS、106(26)、10587-10592。
- Web Speech API 仕様、W3C コミュニティ グループ レポート、2024 年。
- Celebrities Text-to-Speech API ドキュメント、v1、2025。
- Google Gemini API リファレンス: ストリーミングと関数呼び出し、2025。