12/20 まででメディア(送受信)を押さえたので、今日は app層 が現状どう動いているかをコード位置ベースでまとめる。MVPでは状態機械というより「1チャンクASR→LLM→TTS」の直列処理になっている点に注意。
全体像(現行)
session (capture 10s) ──AppEvent::AudioBuffered──────► app::worker
│ ASR(transcribe_chunks)
▼
LLM(generate_answer)
▼
TTS(synth_to_wav)
▼
session <= SessionOut::AppSendBotAudioFile { path }
- appはセッションごとにワーカーを1つ起動し、
AppEventを受け取って処理するだけのシリアル実装。 - SIP/RTPには触れず、AI呼び出しと履歴保持のみ担当。
1) エントリとイベント線表
- セッション起動時に app ワーカーを生成:
src/session/session.rs:108app::spawn_app_worker(...) - appが受け取るイベント(
src/app/mod.rs:12):-
CallStarted { call_id }(セッション確立・イントロ後に送信) -
AudioBuffered { call_id, pcm_mulaw }(受信音声10秒を μ-law で受け取る) -
CallEnded { call_id }(終了通知)
-
イベント送信元(session 側のタイミング):
-
CallStarted: ACK後、イントロ再生前に送信(session.rs:169)。 -
AudioBuffered: Establishedで10秒貯めたら送信(session.rs:209-229)。 -
CallEnded: BYE/タイムアウトなどで終端時に送信(session.rs:242-257ほか)。
2) appワーカーのメインループ
// src/app/mod.rs:18-
pub fn spawn_app_worker(call_id: String, rx: UnboundedReceiver<AppEvent>, session_out_tx: UnboundedSender<SessionOut>) {
let worker = AppWorker::new(call_id, session_out_tx, rx);
tokio::spawn(async move { worker.run().await });
}
impl AppWorker {
async fn run(mut self) {
while let Some(ev) = self.rx.recv().await {
match ev {
AppEvent::CallStarted { .. } => { self.active = true; }
AppEvent::AudioBuffered { pcm_mulaw, .. } => { ... handle_audio_buffer ... }
AppEvent::CallEnded { .. } => break,
}
}
}
}
-
activeフラグが立つまで AudioBuffered は捨てる。 - 並列処理やキャンセルはなく、1イベントずつ直列で処理。
3) ASR→LLM→TTS の直列処理
// src/app/mod.rs:51-
async fn handle_audio_buffer(&mut self, call_id: &str, pcm_mulaw: Vec<u8>) -> anyhow::Result<()> {
// ASR: 1チャンク
let asr_chunks = vec![asr::AsrChunk { pcm_mulaw, end: true }];
let user_text = match asr::transcribe_chunks(call_id, &asr_chunks).await {
Ok(t) => t,
Err(e) => { warn!("ASR failed: {e:?}"); "すみません、聞き取れませんでした。".into() }
};
// LLM: 履歴から簡易プロンプトを構築
let prompt = self.build_prompt(&user_text);
let answer_text = match llm::generate_answer(&prompt).await {
Ok(ans) => ans,
Err(e) => { warn!("LLM failed: {e:?}"); "すみません、うまく答えを用意できませんでした。".into() }
};
self.history.push((user_text.clone(), answer_text.clone()));
// TTS: WAVパスを session に返す
match tts::synth_to_wav(&answer_text, None).await {
Ok(bot_wav) => {
let _ = self.session_out_tx.send(SessionOut::AppSendBotAudioFile { path: bot_wav });
}
Err(e) => { warn!("TTS failed: {e:?}"); }
}
Ok(())
}
- ASR/LLM/TTS すべて同期的に await する単線構造。
- エラー時は定型の謝罪テキストを返すだけで再試行なし。
- TTS失敗時は無音の返却もなく、発話スキップになる。
4) プロンプト構築(履歴あり)
// src/app/mod.rs:86-
fn build_prompt(&self, latest_user: &str) -> String {
let mut prompt = String::new();
for (u, b) in &self.history {
prompt.push_str("User: "); prompt.push_str(u); prompt.push_str("\nBot: "); prompt.push_str(b); prompt.push('\n');
}
prompt.push_str("User: "); prompt.push_str(latest_user);
prompt
}
- シンプルに
User/Botの往復を平文で連結するだけ。システムプロンプトや役割指定はなし。 - 履歴は無制限に伸びる(クリッピングなし)。
5) 現状の割り切り・制約
- 音声入力は「10秒貯めて1発ASR」。被せ検知・部分ASR・中断はなし。
- 状態管理は
activeと履歴のみ。Listening/Thinking/Speaking の分離は未実装。 - LLM/TTS の失敗時にリトライや「無音でも送る」処理はなし。
- プロンプトにシステム指示がないため、キャラ付けや禁止事項は未設定。
- AppEvent/SessionOut は固定型のみ(Barge-in/Cancel/Action 指示などは未導入)。
まとめ(12/21時点の固定点)
- app層はセッションごとに単一ワーカーを起動し、
CallStarted→AudioBuffered→CallEndedを直列処理。 - 音声10秒を μ-law チャンクとして ASR→LLM→TTS で同期処理し、生成WAVパスを
SessionOut::AppSendBotAudioFileで session に戻す。 - 状態機械や被せ対応は未実装で、直列パイプラインのみ。エラー時は定型文でフォールバックし、TTS失敗はスキップ。
- 今後の改善候補: 部分ASR/早口出し、バー ジインで送信停止、履歴クリップ&システムプロンプト導入、TTS失敗時の無音返却やリトライ、LLMアクション拡張。