0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stack Chan を使って Foundry のエージェントを喋らせる - Voice Live API の viseme でリップシンクする

0
Posted at

Stack Chan 単体でエージェントを喋らせる

Microsoft Foundry の Voice Live API は、音声認識・生成AI・音声合成を1本の WebSocket に統合したリアルタイム音声対話 API です。Voice Live APIは過去色々調査しているのですが、Stack Chan手に入れたのでエージェントを直接会話できるようにしてみようと思いました。
ということで、今回はStack Chan (CoreS3 / ESP32-S3) から直接叩いて、口パクしながら Foundry Agent Service のエージェントと会話させるサンプルを作成しました。

コードはGithubで公開しています。

全体概要

最初にStack Chanで実現した全体を紹介します。まずは動いているところです。
Voice Live APIで不必要に会話をひろってしまうと無駄に課金されるため、タップして会話モードに移るようにしています。そのあとStack Chanに話かけるとFoundryに接続したモデルやエージェントと会話できる仕組みです。

以下はFoundry上のエージェントのトレース情報です。Microsoft Foundryでは実際にエージェントと会話した情報はログとして残るため、これらの会話情報からさらにチューニングや改善といったことが容易になるような仕組みが提供されています。
2026-08-27-01-00-16-ae792c1f.png

次に簡単に全体の構成を紹介します。

今回実現した構成

Stack Chanは色々なインターフェース持っているので、マイクとカメラを使って会話しながらカメラで撮った写真に対しても会話できるようにしました。応答はスピーカーから音声を返しています。簡単に図にすると以下のようになります。

Stack Chan と Foundry の間は WebSocket通信です。FoundryのVoice Live APIはエージェントの返答だけでなく色々情報を返すことができます。その1つがVisemeという、音素に合わせた口や顔の形状を表す情報です。これを利用すると音声データに合わせて、口の動きを再現できます。これを利用することでStack Chanの応答音声データに合わせて口パクさせることができます。

  • Foundry のエージェントと音声で会話させる — キーボードの無い端末での認証とVoice Live APIによる音声と画像の提供
  • viseme を使った口の動きの同期 — Voice Live が返す viseme 情報と音声データでそれっぽい会話をStack Chanで実現する。

開発環境

主要なライブラリは以下の通りです。

役割 使ったもの
顔の描画(目・まばたき・表情・口) m5stack-avatar
サーボ、IO エキスパンダ、タッチ StackChan-BSP
ディスプレイ・マイク・スピーカー・カメラ M5Unified / M5CoreS3 / esp32-camera
Voice Live との通信 ArduinoWebsockets / ArduinoJson

接続先は2モードを切替えられるようにしています。Foundry上のエージェントを使う場合はEntra ID認証が必要です。今回はdevice code flowを使った認証で実現しています。

  • モデル直結 — リソースのエンドポイントと API キーだけで動く
  • Foundry Agent Service — ポータルで作ったエージェントに繋ぐ。認証は Entra ID のみ

Voice Live のイベントの流れそのものは以前書いたので、全体像はそちらを参照してください。本記事は Stack Chan で組むうえで必要な部分に絞ります。

Foundry のエージェントと音声で会話する

最初にFoundryのVoice Live APIとの接続に関する準備を進めていきます。

Foundryの準備

まずはFoundryの準備をしましょう。モデル呼出、エージェント呼出いずれにしてもMicrosoft Foundryのサービスにデプロイしておく必要があります。
まずは、FoundryのサービスをAzureで追加します。リージョンは必ず日本にしてください(後述のパフォーマンスの問題につながります)

次に作成されたFoundryのプロジェクトを開き、メニューのモデルと選択し、デプロイタブからデプロイボタンを押して好きなモデルを追加します。今回は画像もインプットに使うので画像も扱えるモデルを追加してください。
2026-08-25-01-41-59-132b0c90.png
これで、モデル直結で呼び出すためのモデルと、エージェントが内部で使うモデルの両方が揃いました。Foundry 側のポータルでの準備は以上です。次は今回のStack Chan向けのエージェントを構築していきます。

エージェントを作成する

続いてエージェントを作ります。今回は REST API から作成します。

ポータルでもエージェントの作成・モデルの選択・instructions の設定はできます。ツールも、MCP サーバーや OpenAPI といった「エンドポイントを持つもの」であれば追加できます。

ただし function calling だけは別で、公式に明記されています。

You can run agents with function tools in the Microsoft Foundry portal. However, the portal doesn't support adding, removing, or updating function definitions on an agent. Use the SDK or REST API to configure function tools.
Use function calling with Microsoft Foundry agents

function ツールはアプリ側が実行するものなので、エンドポイントを登録する形にはなりません。そのためポータルからは定義できず、SDK か REST を使うことになります。

今回だと、look_at_camera はまさにこれで、呼び出すのはエージェント、撮影するのはデバイスという形でローカル側で処理を行います。今回のように、クライアント側にコールバックするような関数を利用するエージェントは、 REST APIで作りましょう。

エージェントの定義を JSON で用意します。人格(instructions)もツールも、まとめてここに書きます。カメラの look_at_camera に加えて、組み込みの web_search も入れました。

stackchan-agent.json
{
  "name": "stackchan",
  "description": "Stack Chan desktop robot voice agent (Voice Live + camera)",
  "definition": {
    "kind": "prompt",
    "model": "gpt-5-mini",
    "reasoning": { "effort": "low" },
    "instructions": "あなたは「スタックちゃん」という小さくてかわいいデスクトップロボットです。明るく元気で、少しおちゃめな性格です。回答は音声で読み上げられるので、短く簡潔に、1〜3文で答えてください。

あなたにはカメラ(look_at_cameraツール)があります。「何が見える?」「何が映ってる?」「これは何?」など視覚に関する質問には、必ずlook_at_cameraツールを呼び出してから答えてください。ツールを呼ばずに見えたふりをして答えるのは禁止です。逆に、視覚と関係ない挨拶や雑談ではカメラを使わないでください。

最新のニュースや調べものを頼まれたときはweb_searchツールを使い、結果は音声向けに短くまとめてください。",
    "tools": [
      {
        "type": "function",
        "name": "look_at_camera",
        "description": "スタックちゃんの目(カメラ)で今見えているものを確認する。「何が見える?」「何が映ってる?」「これは何?」など視覚に関する質問では、見えたふりをせず必ずこのツールを呼び出すこと。",
        "parameters": {
          "type": "object",
          "properties": {
            "question": {
              "type": "string",
              "description": "画像について知りたいこと(ユーザーの質問の言い換え)"
            }
          }
        }
      },
      { "type": "web_search" }
    ]
  }
}

instructions では「見えたふりをして答えるのは禁止」と明記しました。これを書かないと、モデルがツールを呼ばずに適当な情景を答えてしまうことがあります。

この JSON を POST すれば登録できます。認証は Entra ID なので、アクセストークンは Azure CLI から取るのが手軽です。

$endpoint = "https://<your-resource>.services.ai.azure.com"
$project  = "<your-project-name>"
$token    = (az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)

Invoke-RestMethod -Method Post `
  -Uri "$endpoint/api/projects/$project/agents?api-version=v1" `
  -Headers @{ Authorization = "Bearer $token" } `
  -ContentType "application/json; charset=utf-8" `
  -Body (Get-Content stackchan-agent.json -Raw -Encoding UTF8)

用途によってエンドポイントが分かれます。

用途 エンドポイント
新規に作成する POST /api/projects/{project}/agents?api-version=v1
既存のエージェントに追加する POST /api/projects/{project}/agents/{name}/versions
登録済みを確認する GET /api/projects/{project}/agents?api-version=v1

既存のエージェントにツールを足す場合は新しいバージョンとして登録されます。旧バージョンはそのまま残るので、すでに運用しているエージェントに手を入れるときも元に戻せます。

登録が済むと、ポータル側にもツールを持ったエージェントとして表示されます。プレイグラウンドから動作を確認できるので、デバイスを繋ぐ前にここで会話を試しておくと切り分けが楽になります。

2026-08-25-01-52-24-b9a53223.png

接続 URL

まずはFoundry上にデプロイしたリソースに対してVoice Live APIで接続するための作業として接続URLを構成します。エージェントに繋ぐ場合、URL にエージェント名とプロジェクト名を載せ、トークンは Authorization ヘッダで渡します。

url = "wss://" + String(VOICE_LIVE_HOST) +
      "/voice-live/realtime?api-version=" + VOICE_LIVE_API_VERSION +
      "&agent-name=" + AGENT_NAME +
      "&agent-project-name=" + AGENT_PROJECT_NAME;
ws_->addHeader("Authorization", String("Bearer ") + entra::accessToken());

モデル直結ならこちらです。API キーで済みます。

url = "wss://" + String(VOICE_LIVE_HOST) +
      "/voice-live/realtime?api-version=" + VOICE_LIVE_API_VERSION +
      "&model=" + VOICE_LIVE_MODEL +
      "&api-key=" + VOICE_LIVE_API_KEY;

agent-project-name に渡すのはプロジェクト名です。リソース名を入れると agent_not_found になります。

ArduinoWebsockets はヘッダにトークンを含めることができる生の WebSocket クライアントです。何かアプリを構築する場合は基本的にヘッダに入れることができるのですが、ブラウザ(JavaScript系)の WebSocketライブラリ はヘッダを設定できません。同じことを Web ベースの環境でやる場合はクエリ文字列で渡す必要があります。

Entra ID の認証を通す

エージェントモードは API キーが使えません。Entra ID のアクセストークンが必要です。
Stack Chan は単体では入力装置を持っていません。そこで device code flow を使いました。未認証状態でアプリを起動するとdevice code flowを利用して、顔にコードを表示するようにしました。表示されたコードをPC やスマホの microsoft.com/devicelogin で承認してもらう形です。

bool ensureEntraToken() {
    if (entra::tokenValid()) return true;
    setStatus("サインイン じゅんびちゅう", Expression::Doubt);
    bool ok = entra::ensureToken([](const String& code, const String& uri) {
        String msg = "コード: " + code;
        avatar.setSpeechText(msg.c_str());          // 顔の吹き出しに出す
        M5_LOGI("Sign in at %s with code %s", uri.c_str(), code.c_str());
    });
    if (!ok) setStatus("サインイン しっぱい", Expression::Sad);
    return ok;
}

今回は、一度認証させた後は、refresh token は NVS に保存します。2回目以降は起動時に更新するようにしています。

void storeTokens(JsonDocument& doc) {
    accessToken_ = doc["access_token"].as<String>();
    uint32_t expiresIn = doc["expires_in"] | 3600;
    expiresAtMs_ = millis() + (expiresIn > 120 ? expiresIn - 60 : expiresIn) * 1000;

    const char* rt = doc["refresh_token"];
    if (rt) {
        Preferences prefs;
        prefs.begin("entra", false);
        prefs.putString("rt", rt);      // 次回以降はこれで無言更新
        prefs.end();
    }
}

有効期限は気持ち少しだけ切り上げています。有効期限ちょうどでトークン更新に失敗すると原因切り分けが難しくなる可能性があるので。

(参考)モデルモードとエージェントモードの違い

少しだけFoundryにおけるAIモード(Foundryにデプロイした生成AIモデルを呼ぶ)とエージェントモード(Foundry上でビルドしたエージェント)にはその仕様の違いから設定項目が少しだけ異なります。
勘の鋭い方は気づくかもしれないですが、AIモードの場合は生成AIモデルのみFoundry上にあるため、エージェントとして利用するためのInstruction, Toolsなどは、都度定義する必要があります。一方エージェントの場合は、これらのプロンプトやツールを含めて定義しているため不要です。Voice Live APIを利用する場合もこれらの違いが設定に影響します。エージェントを呼び出すつもりで、ツール定義の設定などを送るとエラーになります。
モデルモードとエージェントモードでは設定の置き場所が変わります。同じコードで両方を切り替えられるようにする場合、session.update の組み立てを分岐させます。

エージェントモードでは、session.updateinstructionstoolsresponse.create での instructions 上書きがいずれも使えませんinstructions_configuration_not_supported)。

人格もツールもエージェント側に定義します。カメラを使う場合も、look_at_cameraエージェントの function ツールとして定義しておく必要があります。

visemeの情報で口を動かす

**viseme(口形素)**は、音素に対応する「口の見た目」を表す単位です。音としては別でも口の形では見分けがつかないもの(p / b / m など)は1つにまとまるため、音素より数が少なく、Azure では 0〜21 の22種類で表します。もともとは音声合成に合わせてアバターの口を動かすために用意された仕組みです。

Voice Live は、合成した音声と並行して「いつ・どの viseme か」を送ってきます。これを追いかければ、実際に鳴っている音に口を合わせられます。

session.updateanimation の出力に viseme_id を指定します。これだけで、応答音声と並行して口形素のイベントが流れてきます。

// This is what makes the lip sync possible.
s["animation"]["outputs"].to<JsonArray>().add("viseme_id");

受信側では response.animation_viseme.delta を拾います。届くのは viseme の ID と、音声先頭からのオフセットの2つです。

if (typeIs(type, typeLen, "response.animation_viseme.delta")) {
    JsonDocument doc;
    if (deserializeJson(doc, data, len) == DeserializationError::Ok) {
        uint32_t offset = doc["audio_offset_ms"] | 0;
        uint8_t  id     = doc["viseme_id"] | 0;
        if (cb_.onViseme) cb_.onViseme(offset, id);
    }
    return;
}

Voice Live が返すのは viseme_id だけです。blend shapes や SVG のアニメーションは返ってきません(それらは従来の Speech SDK 側の機能です)。

とはいえ Stack Chan の口は開閉の1自由度しかないので、22種類を開口率に写像できれば十分です。

22種類を開口率に写像する

Azure の viseme は 0〜21 の22種類です。母音は大きく、子音は小さく、無音(0) と p/b/m(21) は完全に閉じる、という方針でテーブルを作りました。

constexpr float kVisemeRatio[22] = {
    0.00f,  //  0: silence
    1.00f,  //  1: ae, ax, ah  (あ)
    1.00f,  //  2: aa          (あ)
    0.70f,  //  3: ao          (お)
    0.50f,  //  4: eh, uh      (え)
    0.50f,  //  5: er          (え)
    0.25f,  //  6: iy, ih, ix  (い)
    0.35f,  //  7: w, uw       (う)
    0.70f,  //  8: ow          (お)
    0.90f,  //  9: aw          (あ)
    0.70f,  // 10: oy          (お)
    0.90f,  // 11: ay          (あ)
    0.50f,  // 12: h
    0.35f,  // 13: r
    0.45f,  // 14: l
    0.20f,  // 15: s, z
    0.25f,  // 16: sh, ch, jh, zh
    0.40f,  // 17: th (voiced)
    0.30f,  // 18: f, v
    0.40f,  // 19: d, t, n
    0.40f,  // 20: k, g, ng
    0.00f,  // 21: p, b, m  (閉口)
};

viseme はロケール非依存なので、日本語のボイスを指定していてもそのまま出ます。表のコメントに日本語の母音を併記してあるのは、値を調整するときの目安です。

再生位置に合わせる

audio_offset_ms音声の先頭からのオフセットです。イベントが届いた瞬間の時刻ではありません。

一方、ESP32 側の音声再生は WebSocket の受信と非同期です。受信したデータはリングバッファに貯めてから鳴らします。つまり**「いま何ミリ秒目を鳴らしているか」を自分で推定して、そこに viseme を突き合わせる**必要があります。

void LipSync::update(uint32_t playbackMs) {
    // 再生位置に達した viseme まで進める
    while (consumed_ < queue_.size() && queue_[consumed_].offsetMs <= playbackMs) {
        target_ = kVisemeRatio[queue_[consumed_].visemeId];
        ++consumed_;
    }

    uint32_t now = millis();
    float dt = (now - lastUpdate_) / 1000.0f;
    lastUpdate_ = now;

    float k = kSmoothing * dt;      // 18.0f / 秒
    if (k > 1.0f) k = 1.0f;
    current_ += (target_ - current_) * k;

    if (avatar_) avatar_->setMouthOpenRatio(current_);
}

その再生位置の推定がこちらです。ここが一番ハマった部分でした。

uint32_t AudioIO::playbackPositionMs() const {
    if (!started_) return 0;
    uint32_t pause = stallAccum_ + (stalled_ ? millis() - stallStart_ : 0);
    uint32_t elapsed = millis() - anchorMs_ - pause;
    return elapsed < queuedMs_ ? elapsed : queuedMs_;
}

素直に「再生開始からの経過時間」を使うと、一度ズレが始まると、戻らなくなります。ネットワークが一瞬詰まって音声が途切れると、その間も経過時間だけは進むためです。音は止まっているのに口だけ先に進み、以降ずっと口パクが先行します。

そこで、停止していた時間を累積して差し引いています(stallAccum_)。これで、音が止まっている間は viseme のタイムラインも止まります。

kSmoothing による平滑化も効きます。viseme はそれなりに細かい間隔で届くので、そのまま開口率に代入するとカクつきます。指数的に目標値へ近づける形にすると、機械的な動きが自然に見えるようになります。

音声パイプライン

口パクの前提として、音声側の設計も書いておきます。
今回の実装は、半二重です。M5Unified はマイクとスピーカーを同時に使えないため、「聞くモード」と「話すモード」を切り替えています。バージイン(相手の発話中に割り込む)は未対応です。

項目 値と理由
フォーマット 入出力とも PCM16 16kHz mono。マイクを 16kHz で録るのでリサンプリング不要
送信チャンク 100ms(Base64 後で約 4.3KB)。VAD の反応と WSS のフレーム数のバランス
再生バッファ 500ms 貯めてから再生開始。アンダーラン時は貯め直す(細切れ再生の防止)

エコーキャンセルとノイズ抑制はサーバー側に任せました。スピーカーがマイクのすぐ隣にあるので、これが無いと自分の声を拾って会話が成立しません。

// The speaker sits right next to the mic — let the service clean it up.
s["input_audio_noise_reduction"]["type"]   = "azure_deep_noise_suppression";
s["input_audio_echo_cancellation"]["type"] = "server_echo_cancellation";

もう1つ、response.audio.delta は ArduinoJson を通さずに処理しています。大きな Base64 文字列を JSON パーサに通すとメモリと時間を食うため、文字列スキャンで直接デコードし、デコード先は PSRAM にしました。内部 RAM の消費を抑えるためです。

カメラ画像を会話に添付する

「今何が見える?」と聞くと、その場で撮影して画像を会話に添付します。エージェント側で look_at_camera を function ツールとして定義しておき、呼ばれたら撮影して結果を返す形です。

画像は conversation.item.create に input_image として載せます。

JsonObject img = content.add<JsonObject>();
img["type"] = "input_image";
// The service expects "image_url" even on api-version 2026-04-10, where
// the reference still documents this field as "url".
img["image_url"] = dataUrl;
img["detail"]    = "low";

フィールド名は image_url です。API リファレンスが url と書いているバージョンでも、実サーバは image_url を要求します。Missing required parameter: 'image_url' で気づきました。

実装上の注意として、ツール呼び出しは WebSocket のコールバックで受け、実際の撮影と送信は loop() 側で実行しています。コールバック内で撮影までやると、その間ソケットの処理が止まるためです。

以上で一通り、実装が完了しました。次に実機にデプロイして検証しました。

実機検証

ここまでで実装は一通り揃ったので、実機で会話を試しましたが、疎通はうまくいっているものの、会話はまともにできませんでした。

症状は次のとおりです。

  • 音声が途切れる
  • 認識結果がおかしい(言っていない単語になる)
  • しばらく喋っていると突然切断される

当然、最初は何かプログラム上のバグだと思って調査していたのですが、特に上記の症状を引き起こす実装が見当たらず、色々調査した結果リージョン(距離)が原因でした。

Sweden Centralは遠かった

日本からするとSweden Centralは遠いのですが、なぜ遠いと今回の事象が起きるのか。計測ログを入れて切り分けました。

// Realtime audio needs a sustained ~43KB/s in each direction; these lines are
// the quickest way to tell a network problem (low chunk rate / low download
// rate) from an audio one (RMS near zero).
uint32_t micChunks = 0, micSendMs = 0, micRms = 0;

16kHz の PCM16 は、片方向で約 43kB/s を維持し続ける必要があります。マイクの RMS は正常なのに送信レートが上がらない状況でした。つまり音声処理ではなくネットワーク側です。
色々調査したのですが、、、

  • ESP32 の TCP 送信バッファは 5.7KB 程度で固定
  • Sweden Centralで、RTT が約 250ms
  • バッファが空くのを待ちながら送るので、スループットの上限は 5.7KB ÷ 0.25秒 ≒ 23kB/s

計算上ですが、必要な 43kB/s の半分ほどしか出ません。バッファサイズと往復遅延の積で頭打ちになっているようでした。コードをいくら直しても届かない性質の問題でした。

リージョンを移して解消

近いリージョン(東日本)にリソースを作り直したところ、そのまま解決しました。RTT が下がれば同じバッファでもスループットが出ます。

PC やスマホなら TCP バッファが大きいので同じ構成でも問題になりません。バッファの小さい端末ではこういった帯域のちょっとしたことまでちゃんとチューニングが必要なのは、いい気付きでした。

あと、ちょっとしたことですが、WiFi.setSleep(false) を忘れると同じような症状になります。モデムの省電力が効いてスループットが落ちるためです。こちらは1行で済みます。

ハマりどころ

上で触れていないものも含めて、実機で踏んだもののうちドキュメントから読み取りにくかった点をまとめます。

症状 原因と対策
音声が途切れる、認識がおかしい、突然切断 リージョンが遠い(前述)。近いリージョンに移す
送信レートが上がらない WiFi.setSleep(false) を忘れている
start_ssl_client: -1 で即失敗 ArduinoWebsockets の ESP32 実装は setInsecure() が下層に伝わらない。ルート CA の設定が必須
Missing required parameter: 'image_url' input_image のフィールド名。リファレンスが url と書くバージョンでも image_url
ツールを呼ばずに「見たふり」で答える 指示に「必ずツールを呼ぶ・見たふり禁止」を明記する
agent_not_found agent-project-nameプロジェクト名を渡す(リソース名ではない)
instructions_configuration_not_supported エージェントモードでは instructions/tools をクライアントから送れない。エージェント側に定義する
挨拶の途中で勝手にカメラを使う モデルモードでは response.createtool_choice: "none" が効かないことがある。ツール無しでセッションを開始し、挨拶完了後に session.update で渡す
StackChan-BSP がコンパイルエラー arduino-esp32 3.x 前提。2.x では -DUART_SCLK_DEFAULT=UART_SCLK_APB を定義する

まとめ

  • animation: { outputs: ["viseme_id"] } の1行で口形素が流れてくる。返るのは viseme_id だけだが、開閉1自由度の口なら22種類の写像でもそれっぽくなりました。
  • 口パクは audio_offset_ms を実際の再生位置に合わせる。素直に経過時間を使うと、アンダーランで一度ズレたら戻らない。停止時間を差し引く必要がある
  • エージェントモードは Entra ID 必須。キーボードの無い端末では device code flow でコードを顔に出し、refresh token を NVS(不揮発性ストレージ) に保存すれば、トークンの更新が楽になります。
  • エージェントモードでは instructions もツールもエージェント側に置く。クライアントからは送る必要はありません。
  • リアルタイム音声には片方向 43kB/s が要る。ESP32 は TCP バッファが 5.7KB 程度なので、RTT 250ms のリージョンでは 23kB/s 頭打ちで足りない。帯域確保のために色々工夫が必要(例:リージョン選択)

これで、一通りStack ChanでFoundryエージェントと会話することはできました。あと、バージイン(response.cancel による発話割り込み)、応答内容に応じた表情の切替えなどもできるといいかも。ただ、半二重の制約があるので、バージインは音声の入出力切り替えとセットで考えることになりそうです。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?