🚀 クイックスタート

このクイックスタートでは、SkyWay Embedded SDK(以下、Embedded SDK)を用いたシンプルなアプリケーションを動作させ、そのコードを通じてEmbedded SDKの基本的な使い方を説明します。

本ガイドでは、C言語の基本的な文法を理解していることと、ESP-IDFの開発環境がセットアップ済みであることを前提としています。 ESP-IDFのセットアップについては、Espressifの公式ガイド(ESP32-S3ESP32-P4)を参照してください。

クイックスタートの想定環境

  • ESP-IDF : v5.5.2
  • 開発ボード : PSRAMを8MB以上搭載したESP32-S3、またはESP32-P4
    • ESP32-P4はWi-Fiを内蔵しないため、Wi-Fiコプロセッサ(ESP32-C6等)を搭載し、esp_hosted経由で利用できる開発ボードが必要です
  • ネットワーク : 開発ボードから接続できるWi-Fi環境

また、映像・音声の受信、データの送信には他のSDKが必要です。 あらかじめ、他のSDKのサンプルコードなどを参考に、映像・音声・データの受信(Subscribe)、データの送信(Publish)を行うアプリケーションを準備してください。

アプリケーションの概要

このクイックスタートでは、ESP32から映像・音声・データをPublishし、他のMemberからのデータをSubscribeするアプリケーションを動作させます。

カメラやマイクは使用せず、あらかじめエンコード済みの映像(H.264のカラーバー)と音声(Opusの440Hzトーン)をソースコードに埋め込んで送信します。

このアプリケーションは以下の機能を持ちます。

  • Wi-Fiに接続し、SNTPで時刻を合わせる
  • menuconfigで指定した名前のRoomに参加する
  • 他のMemberに対して、埋め込んだ映像をPublishする
  • 他のMemberに対して、埋め込んだ音声をPublishする
  • 他のMemberに対して、一定間隔で文字列データをPublishする
  • 他のMemberがPublishしているデータをSubscribeし、ログに出力する

アプリケーション ID とシークレットキーを取得する

SkyWay への登録がまだの方はSkyWayコンソールへログインし、以下の 3 つの操作を行い、アプリケーション ID とシークレットキーを取得してください。

  1. 「アプリケーションを作成」ボタンを押す
  2. アプリケーション名を入力して「作成」ボタンを押す
  3. アプリケーション一覧からアプリケーション ID とシークレットキーを控えておく(後の手順で利用します)。

クイックスタートの取得

Embedded SDKは、GitHubリポジトリでクイックスタートのソースコードとヘッダーファイル群を、GitHubのリリースでビルド済みのバイナリ(libskyway-embedded.a)を配布しています。

まず、GitHubリポジトリをcloneし、クイックスタートのディレクトリへ移動します。以降のコマンドは、すべてこのディレクトリで実行します。

git clone https://github.com/skyway/embedded-sdk.git cd embedded-sdk/examples/quickstart

次に、Embedded SDKのバイナリをGitHubのリリースからダウンロードし、libs/<ターゲット名>/libskyway-embedded.a として配置してください。 お使いの開発ボードに合わせ、ESP32-S3向けかESP32-P4向けのバイナリをご利用ください。

配置後のディレクトリ構成を以下に示します。

embedded-sdk/ ├── include/ (Embedded SDKのヘッダーファイル群) └── examples/ └── quickstart/ ├── CMakeLists.txt ├── skyway.cmake ├── partitions.csv ├── sdkconfig.defaults ├── sdkconfig.defaults.esp32s3 ├── sdkconfig.defaults.esp32p4 ├── libs/ │ ├── esp32s3/libskyway-embedded.a (リリースから配置したバイナリ) │ └── esp32p4/libskyway-embedded.a └── main/ ├── CMakeLists.txt ├── idf_component.yml ├── Kconfig.projbuild ├── main.c ├── network.c ├── network.h ├── media_dummy.c └── media_dummy.h

各ファイルの役割は以下のとおりです。

ファイル役割
include/Embedded SDKのヘッダーファイル群
libs/ターゲットごとのEmbedded SDKのバイナリ
skyway.cmakeヘッダーファイル群とバイナリをアプリケーションに組み込むCMakeスクリプト。main/CMakeLists.txt から読み込まれます
main/main.cSkyWayを操作するアプリケーション本体。後述の「コードの解説」で説明します
main/network.c, main/network.hWi-Fiへの接続とSNTPによる時刻同期
main/media_dummy.c, main/media_dummy.h送信用に埋め込んだエンコード済みの映像・音声フレーム
main/Kconfig.projbuildmenuconfigに表示する設定項目(Wi-Fi、アプリケーション ID、Room名など)の定義
main/idf_component.ymlEmbedded SDKが依存する esp_websocket_client と、ESP32-P4でWi-Fiコプロセッサを利用するためのコンポーネントの依存定義
sdkconfig.defaultsEmbedded SDKの動作に必要なESP-IDFの設定
sdkconfig.defaults.esp32s3, sdkconfig.defaults.esp32p4ターゲットごとのPSRAMやフラッシュの設定
partitions.csvアプリケーション領域を3MBに広げたパーティションテーブル

Embedded SDKはビルド済みのバイナリとして配布しているため、ビルド時と同じ環境でリンクする必要があります。以下のバージョンと設定を固定して利用してください。

  • ESP-IDF : v5.5.2
  • espressif/esp_websocket_client : 1.8.0(main/idf_component.yml で指定しています)
  • sdkconfig.defaults に含まれる設定

ターゲットの設定

ESP-IDFの環境変数を読み込み、お使いの開発ボードに合わせてターゲットを設定します。

# ESP-IDFのインストール先に合わせてパスを読み替えてください . $HOME/esp/esp-idf/export.sh # ESP32-S3 の場合 idf.py set-target esp32s3 # ESP32-P4 の場合 idf.py set-target esp32p4

sdkconfig.defaults と、ターゲットに対応する sdkconfig.defaults.<ターゲット名> が自動的に読み込まれ、sdkconfig が生成されます。

注意 sdkconfig.defaults.esp32s3 はPSRAM 8MB(Octal SPI)のモジュール(例:ESP32-S3-WROOM-1-N8R8)を前提としています。

sdkconfig.defaults.esp32p4 はWi-Fiコプロセッサ(ESP32-C6等)をesp_hosted経由で利用する設定です。コプロセッサとの接続方式やピン配置はボードごとに異なるため、既定値で接続できない場合はmenuconfigの「Component config > ESP-Hosted config」でボードの回路に合わせてください。

アプリケーションの設定

menuconfigでWi-Fiの接続情報と、SkyWayコンソールで取得したアプリケーション ID とシークレットキーを設定します。

idf.py menuconfig

SkyWay Quickstart Configuration メニューを開き、以下の項目を設定してください。

項目設定する内容
WiFi SSID接続するWi-FiのSSID
WiFi Password接続するWi-Fiのパスワード
SkyWay AppIDSkyWayコンソールで取得したアプリケーション ID
SkyWay SecretKeySkyWayコンソールで取得したシークレットキー
Room name参加または作成するRoomの名前(デフォルト: skyway-quickstart
Member nameRoomに参加する際のMember名(デフォルト: esp32

このガイドでは検証用として、アプリケーション内でSkyWay Auth Tokenを生成します。 エンドユーザーの環境でアプリケーションを実行する場合など、シークレットキーをエンドユーザーに知られないようにする必要がある場合は、アプリケーションサーバーを構築し、そちらでSkyWay Auth Tokenを生成してクライアントアプリに払い出すことを推奨します。

SkyWay Auth Token の詳細については、SkyWay Auth Token のドキュメントを参照してください。

アプリケーションの実行

ビルドして開発ボードに書き込み、ログを表示します。 <PORT> には開発ボードが接続されているシリアルポート(例: /dev/ttyACM0)を指定します。

idf.py -p <PORT> flash monitor

クイックスタートが正常に起動すると、以下のようなログが出力されます。

I (xxxx) quickstart: Wi-Fi connected. ssid=<WiFi SSID> I (xxxx) quickstart: Clock synchronized. ... I (xxxx) quickstart: Joined. room_name=skyway-quickstart room_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx member_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx I (xxxx) quickstart: Published. content_type=0 publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx I (xxxx) quickstart: Published. content_type=1 publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx I (xxxx) quickstart: Published. content_type=2 publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

続いて、あらかじめ用意した、映像と音声のSubscribe、およびデータのPublishを行うアプリケーションを実行します。 Room名は、menuconfigで指定したRoom名と同じにしてください。

受信側でESP32のVideoStreamとAudioStreamをSubscribeすると、カラーバーの映像と440Hzのトーンが再生されます。 また、ESP32から1秒間隔で送信される hello from esp32 #0, hello from esp32 #1, ... という文字列が、受信側のアプリケーションで受信できることを確認してください。

受信側でデータのPublishが行われると、Embedded SDKのクイックスタートのログに以下のように表示されます。このログは、データを受信していることを示しています。

I (xxxx) quickstart: Stream published. publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx publisher_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx content_type=2 I (xxxx) quickstart: Subscribed. subscription_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx I (xxxx) quickstart: Received data. publication_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx data=hello length=5 bytes

以上で、クイックスタートのアプリケーションの動作確認は完了です。

コードの解説

ここからは、main/main.c のコードを上から順に読みながら、Embedded SDKの使い方を説明します。

Embedded SDKと直接関係しない network.cmedia_dummy.c については、次の点だけ押さえておけば十分です。

  • network.c は、Wi-Fiへ接続した後、SNTPで時刻を合わせます。SkyWay Auth Tokenには有効期限が含まれるため、時刻が正しくないと認証に失敗します。また、Wi-Fiの省電力機能(modem sleep)が有効な場合、Embedded SDKの通信が不安定になることがあるため、接続後に esp_wifi_set_ps(WIFI_PS_NONE) で無効にしています。
  • media_dummy.c は、カメラやマイクの代わりとなるエンコード済みのフレームを、バイト配列として埋め込んだものです。映像は320x240、12fpsのH.264(Constrained Baseline、1秒ごとにキーフレーム)、音声は48kHz、2チャンネル、60msフレームのOpusです。Embedded SDKは映像・音声のエンコードを行わないため、アプリケーションからはエンコード済みのフレームを渡します。フレームの要件は「映像・音声の送信処理」で説明します。

ヘッダーファイルのインクルードと定数の定義

Embedded SDKのヘッダーファイルは skyway/ ディレクトリ以下に機能ごとに分かれています。 このクイックスタートでは、SkyWay Auth Token、SkyWay Context、Logger、Room、Member、LocalPerson、Publication、Subscriptionのヘッダーファイルを使用します。

#include <stdio.h> #include <string.h> #include <esp_log.h> #include <freertos/FreeRTOS.h> #include <freertos/queue.h> #include <freertos/task.h> #include <sdkconfig.h> #include <skyway/auth_token/auth_token.h> #include <skyway/context/context.h> #include <skyway/logger/logger.h> #include <skyway/room/local_person.h> #include <skyway/room/member.h> #include <skyway/room/publication.h> #include <skyway/room/room.h> #include <skyway/room/subscription.h> #include "media_dummy.h" #include "network.h" static const char* TAG = "quickstart"; // アプリ本体のタスクのスタックサイズ(バイト)と優先度。Subscribeを行うタスクも同じ値を使います。 #define APP_TASK_STACK_SIZE (16384U) #define APP_TASK_PRIORITY (1U) // 映像・音声の送信タスクのスタックサイズ(バイト)と優先度。送信を優先するため、SkyWay Embedded SDKの既定(優先度4)より高くします。 #define SEND_TASK_STACK_SIZE (4096U) #define SEND_TASK_PRIORITY (5U) // メインループの周期(ミリ秒)。1周ごとにデータを1通送ります。 #define APP_LOOP_INTERVAL_MS (1000U) // 送信するデータメッセージの書式と最大長(NUL終端を含む)。固定の文言とMember名に、連番(uint32_tの最大10桁)を足した長さ。 #define DATA_MESSAGE_PREFIX "hello from " CONFIG_SKYWAY_QUICKSTART_MEMBER_NAME " #" #define DATA_MESSAGE_SIZE (sizeof(DATA_MESSAGE_PREFIX) + 10U) // 参加したRoomと自分のMember。 static skw_room_id_t s_room_id = {0}; static skw_room_member_id_t s_member_id = {0}; // PublishしたStreamのPublicationID。 static skw_room_publication_id_t s_video_publication_id = {0}; static skw_room_publication_id_t s_audio_publication_id = {0}; static skw_room_publication_id_t s_data_publication_id = {0}; // Subscribeする対象のPublicationIDを、イベントハンドラからSubscribeを行うタスクへ渡すキュー。 static QueueHandle_t s_subscribe_queue = NULL;

Embedded SDKでは、RoomやMember、PublicationなどのIDを文字列として扱います。 skw_room_id_tskw_room_publication_id_t は、それぞれのIDを格納できる長さの char 配列です。

SkyWay Contextの作成

SkyWayを利用する際の起点となるオブジェクトのSkyWay Contextを作成します。

Embedded SDKでは、以下の順番でSkyWayの利用を開始します。

  1. skw_context_init() でSkyWay Contextを初期化する
  2. skw_auth_token_generate_for_dev() でSkyWay Auth Tokenを生成する
  3. skw_context_setup() でSkyWay Auth Tokenを渡し、SkyWayサーバーへ接続する

skw_auth_token_generate_for_dev() は、アプリケーション ID とシークレットキーから、開発用のSkyWay Auth Tokenを生成する関数です。 クイックスタートではmenuconfigで設定した値を使用します。

注意 skw_auth_token_generate_for_dev() が生成するSkyWay Auth Tokenの有効期限は1日です。 有効期限が切れるとSkyWayサーバーとの通信ができなくなるため、長時間動作させるアプリケーションでは、有効期限が切れる前に新しいSkyWay Auth Tokenを用意し、skw_context_update_auth_token() で更新する必要があります。 このクイックスタートは動作確認を目的としているため、更新処理は実装していません。

// -------------------------- // SkyWayの操作 // -------------------------- // Contextを初期化し、SkyWayサーバーへ接続する関数。 static bool skyway_start(void) { if (skw_context_init() != SKW_CONTEXT_OK) { ESP_LOGE(TAG, "Failed to initialize the context."); return false; } skw_auth_token_t auth_token = {0}; if (skw_auth_token_generate_for_dev(&auth_token, CONFIG_SKYWAY_QUICKSTART_APP_ID, CONFIG_SKYWAY_QUICKSTART_SECRET_KEY) != SKW_AUTH_TOKEN_OK) { ESP_LOGE(TAG, "Failed to generate the auth token. Check the AppID and the SecretKey."); return false; } if (skw_context_setup(&auth_token, NULL) != SKW_CONTEXT_OK) { ESP_LOGE(TAG, "Failed to setup the context."); return false; } return true; }

Embedded SDKの各関数は、成功時に SKW_XXX_OK を返します。それ以外の値はエラーコードです。

Roomへの参加

指定した名前のRoomに参加します。 下記のコードでは、skw_room_find_or_create() でRoomを検索または作成し、skw_room_join() でそのRoomに参加します。

なお、Embedded SDKの関数に渡すパラメータは、SKW_XXX_PARAMS_INIT() マクロで初期化します。指定しなかったフィールドには既定値が入ります。 また、RoomやMemberの情報を受け取る場合は、出力先のバッファを用意し、skw_room_data_field_t などの構造体にポインタを設定して渡します。

注意 開発ボードのリセットなどで退出処理を経ずにアプリケーションが再起動した場合、前回参加したMemberが一時的にRoomに残ってしまうことがあります。 同名のMemberが存在する場合はRoomに参加することができません。 そのため、nameを指定してRoomに参加する場合などは参加する前に、同じ名前のMemberがいれば退出させる必要があります。

// Roomを探すか作成し、参加する関数。前回の実行が残った同名のMemberが居れば、先に退出させます。 static bool room_join(void) { skw_room_member_id_t member_ids[SKW_ROOM_CAPACITY_MEMBERS] = {0}; skw_room_member_name_t member_names[SKW_ROOM_CAPACITY_MEMBERS] = {0}; skw_room_member_data_fields_t members = {0}; for (uint32_t i = 0U; i < SKW_ROOM_CAPACITY_MEMBERS; i++) { members[i].id = &member_ids[i]; members[i].name = &member_names[i]; } uint32_t member_count = 0U; skw_room_name_t room_name = {0}; skw_room_data_field_t room_fields = { .id = &s_room_id, .name = &room_name, .members = &members, .member_count = &member_count, }; // menuconfigで指定したルームを検索する。ルームが存在しなければ作成する。 const skw_room_find_or_create_params_t room_params = SKW_ROOM_FIND_OR_CREATE_PARAMS_INIT( .name = CONFIG_SKYWAY_QUICKSTART_ROOM_NAME ); const skw_room_err_t room_err = skw_room_find_or_create(&room_params, &room_fields); if (room_err != SKW_ROOM_OK) { ESP_LOGE(TAG, "Failed to find or create the room: %d", (int)room_err); return false; } // Join予定の名前を持つメンバーが居れば退出させます。 for (uint32_t i = 0U; i < member_count; i++) { if (strcmp(member_names[i], CONFIG_SKYWAY_QUICKSTART_MEMBER_NAME) != 0) { continue; } const skw_room_member_leave_params_t leave_params = SKW_ROOM_MEMBER_LEAVE_PARAMS_INIT( .room_id = s_room_id, .member_id = member_ids[i] ); if (skw_room_member_leave(&leave_params) == SKW_ROOM_MEMBER_OK) { ESP_LOGI(TAG, "Left an existing member. name=%s member_id=%s", member_names[i], member_ids[i]); } } skw_room_member_data_field_t member_fields = { .id = &s_member_id, }; // menuconfigで指定した名前でルームに参加します。 const skw_room_join_params_t join_params = SKW_ROOM_JOIN_PARAMS_INIT( .id = s_room_id, .name = CONFIG_SKYWAY_QUICKSTART_MEMBER_NAME ); if (skw_room_join(&join_params, &member_fields) != SKW_ROOM_OK) { ESP_LOGE(TAG, "Failed to join the room."); return false; } ESP_LOGI(TAG, "Joined. room_name=%s room_id=%s member_id=%s", room_name, s_room_id, s_member_id); return true; }

VideoStream・AudioStream・DataStreamのPublish

Roomに参加した後、VideoStream、AudioStream、DataStreamをPublishします。

Embedded SDKでは、Publishする際に content_type でStreamの種類(Video/Audio/Data)を指定します。

Publishする際のオプションにて、そのPublicationの通信方式を指定することができます。 Embedded SDKは現在P2Pのみに対応しているため、SKW_ROOM_PUBLICATION_TYPE_P2P を指定します。

Publishが成功すると、PublicationのIDが out_publication_id に格納されます。このIDは、後の送信処理で利用します。

// 指定した種別のStreamをPublishする関数。 static bool publish_stream(skw_room_publication_content_type_t content_type, skw_room_publication_id_t* out_publication_id) { skw_room_publication_data_field_t fields = { .id = out_publication_id, }; // 指定したcontent_typeのStreamをPublishします。 const skw_room_local_person_publish_params_t params = SKW_ROOM_LOCAL_PERSON_PUBLISH_PARAMS_INIT( .room_id = s_room_id, .member_id = s_member_id, .content_type = content_type, .type = SKW_ROOM_PUBLICATION_TYPE_P2P ); if (skw_room_local_person_publish(&params, &fields) != SKW_ROOM_LOCAL_PERSON_OK) { ESP_LOGE(TAG, "Failed to publish. content_type=%d", (int)content_type); return false; } ESP_LOGI(TAG, "Published. content_type=%d publication_id=%s", (int)content_type, *out_publication_id); return true; }

映像・音声の送信処理

PublishしたVideoStreamとAudioStreamで、埋め込んだフレームを繰り返し送信します。

Embedded SDKでは、映像フレームは skw_room_publication_send_video_frame()、音声フレームは skw_room_publication_send_audio_frame() で送信します。 どちらも、PublicationのID、エンコード済みのフレームデータ、フレームのタイムスタンプ(pts、ミリ秒)を渡します。

映像と音声は、それぞれ専用のタスクでフレーム間隔ごとに送信します。 送信の手順は映像と音声で共通のため、送るデータや送信関数をまとめた media_sender_t 構造体をタスクの引数で受け取り、1つのタスク関数で両方を扱います。

映像・音声フレームの要件 Embedded SDKに渡すフレームは、以下の形式である必要があります。

  • 映像: H.264のConstrained Baselineプロファイル、レベル3.1(1280x720、30fpsまで)。Bフレームは使用できません。1回の呼び出しで1フレームを渡します。各NALユニットの先頭にスタートコードを付けたAnnex B形式で、キーフレームにはSPSとPPSを含めてください。
  • 音声: Opus。サンプリングレート48kHz、2チャンネル。1回の呼び出しで1フレーム(1パケット)を渡します。
  • pts: ミリ秒単位の表示タイムスタンプ。前のフレームからの経過時間進めた値を指定してください。

パケロスなどによって相手が映像を復号できない場合、キーフレームの送信要求が skw_room_publication_register_handlers() で登録した on_keyframe_request に通知されます。カメラの映像をエンコードして送る場合は、この要求に応じてSPSとPPSを含むキーフレームを生成してください。このクイックスタートでは、埋め込んだ映像が1秒ごとにキーフレームを含むため、このハンドラを登録していません。

// 映像・音声の送信タスクに渡す、ダミーStreamの送り方。 typedef struct { const char* task_name; const uint8_t* data; // フレームデータの先頭 const media_dummy_frame_info_t* frames; // 各フレームの位置 const uint32_t* frame_count; uint32_t interval_ms; // フレーム間隔 const skw_room_publication_id_t* publication_id; skw_room_publication_err_t (*send)(const skw_room_publication_send_frame_params_t* params); } media_sender_t; static const media_sender_t s_video_sender = { "video", g_media_dummy_video_data, g_media_dummy_video_frames, &g_media_dummy_video_frame_count, MEDIA_DUMMY_VIDEO_FRAME_INTERVAL_MS, &s_video_publication_id, skw_room_publication_send_video_frame, }; static const media_sender_t s_audio_sender = { "audio", g_media_dummy_audio_data, g_media_dummy_audio_frames, &g_media_dummy_audio_frame_count, MEDIA_DUMMY_AUDIO_FRAME_INTERVAL_MS, &s_audio_publication_id, skw_room_publication_send_audio_frame, }; // ダミーの映像・音声Streamを繰り返し送信するタスク。引数のmedia_sender_tで送り方を切り替えます。 static void media_send_task(void* pvParameters) { const media_sender_t* sender = (const media_sender_t*)pvParameters; TickType_t last_wake = xTaskGetTickCount(); uint32_t index = 0U; uint32_t pts_ms = 0U; while (true) { const media_dummy_frame_info_t* frame = &sender->frames[index]; const skw_room_publication_send_frame_params_t params = SKW_ROOM_PUBLICATION_SEND_FRAME_PARAMS_INIT( .publication_id = *sender->publication_id, .data = &sender->data[frame->offset], .length = frame->size, .pts = pts_ms ); const skw_room_publication_err_t err = sender->send(&params); if (err != SKW_ROOM_PUBLICATION_OK) { ESP_LOGW(TAG, "Failed to send a %s frame: %d", sender->task_name, (int)err); } index = (index + 1U) % *sender->frame_count; pts_ms += sender->interval_ms; vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(sender->interval_ms)); } }

データの送信処理

定期的にDataStreamでデータを送信します。

DataStreamでは、skw_room_publication_send_data() に任意のバイト列を渡して送信します。 このクイックスタートでは、Member名と連番を含む文字列を送信します。

// 指定したDataStreamのPublicationへメッセージを送る関数。 static void send_data_message(const char* publication_id, uint32_t message_count) { char message[DATA_MESSAGE_SIZE]; const int32_t length = snprintf(message, sizeof(message), DATA_MESSAGE_PREFIX "%u", (unsigned int)message_count); if ((length <= 0) || ((size_t)length >= sizeof(message))) { ESP_LOGE(TAG, "The data message does not fit in the buffer. "); return; } // 指定したDataStreamへメッセージを送信します。 const skw_room_publication_send_data_params_t params = SKW_ROOM_PUBLICATION_SEND_DATA_PARAMS_INIT( .publication_id = publication_id, .data = (const uint8_t*)message, .length = (size_t)length ); const skw_room_publication_err_t err = skw_room_publication_send_data(&params); if (err != SKW_ROOM_PUBLICATION_OK) { ESP_LOGW(TAG, "Failed to send the data: %d", (int)err); } }

DataStreamのSubscribe

他のMemberがPublishしているDataStreamをSubscribeします。

Embedded SDKでは、イベントハンドラの中からSkyWayの操作を行うことはできません。 そのため、イベントハンドラではSubscribeする予定のPublicationのIDをキューに積むだけにし、実際のSubscribeは専用のタスクで行います。

skw_room_local_person_subscribe() は、Streamが利用可能になるまで処理をブロックします。

// -------------------------- // Subscribe // -------------------------- // キューから取り出したPublicationを順にSubscribeするタスクです。 static void subscribe_task(void* pvParameters) { (void)pvParameters; while (true) { skw_room_publication_id_t publication_id = {0}; if (xQueueReceive(s_subscribe_queue, publication_id, portMAX_DELAY) != pdTRUE) { continue; } skw_room_subscription_id_t subscription_id = {0}; skw_room_subscription_data_field_t fields = { .id = &subscription_id, }; const skw_room_local_person_subscribe_params_t params = SKW_ROOM_LOCAL_PERSON_SUBSCRIBE_PARAMS_INIT( .room_id = s_room_id, .member_id = s_member_id, .publication_id = publication_id ); // 失敗時(受信の上限超過、Videoが無効、確立できず等)はSkyWay Embedded SDKがSubscriptionを解除します。 if (skw_room_local_person_subscribe(&params, &fields) == SKW_ROOM_LOCAL_PERSON_OK) { ESP_LOGI(TAG, "Subscribed. subscription_id=%s publication_id=%s", subscription_id, publication_id); } else { ESP_LOGW(TAG, "Failed to subscribe. publication_id=%s", publication_id); } } } // SubscribeするPublicationをキューへ積む関数。 static void enqueue_subscribe(const char* publication_id) { skw_room_publication_id_t queued_id = {0}; (void)strlcpy(queued_id, publication_id, sizeof(queued_id)); if (xQueueSend(s_subscribe_queue, queued_id, 0) != pdTRUE) { ESP_LOGE(TAG, "Failed to queue the subscribe. publication_id=%s", publication_id); } } // Subscribeのキューと、それを処理するタスクを作る関数。 static bool start_subscribe_task(void) { // SkyWay Embedded SDKが持てるPublicationの数だけキューを確保します。 s_subscribe_queue = xQueueCreate(SKW_ROOM_CAPACITY_PUBLICATIONS, sizeof(skw_room_publication_id_t)); if (s_subscribe_queue == NULL) { ESP_LOGE(TAG, "Failed to create the subscribe queue."); return false; } // Subscribeタスクを作成します。 if (xTaskCreate(&subscribe_task, "subscribe", APP_TASK_STACK_SIZE, NULL, APP_TASK_PRIORITY, NULL) != pdPASS) { ESP_LOGE(TAG, "Failed to create the subscribe task."); return false; } return true; }

次に、Roomに参加した時点ですでにPublishされているPublicationをSubscribeします。 今後PublishされるPublicationは、後述するイベントハンドラでSubscribeします。

skw_room_find_by_id() で参加中のRoomの情報を取得し、その中のPublication一覧から、自身以外のMemberがPublishしているDataStreamを選んでキューに積みます。

自身のPublicationはSubscribeできません。publisher_id が自身のMemberのIDと等しい場合はSubscribeしないように実装しています。

// 参加した時点で存在していた自分以外のData PublicationをSubscribeする関数。以後はPublishのイベントで購読します。 static void subscribe_existing_publications(void) { skw_room_publication_id_t publication_ids[SKW_ROOM_CAPACITY_PUBLICATIONS] = {0}; skw_room_member_id_t publisher_ids[SKW_ROOM_CAPACITY_PUBLICATIONS] = {0}; skw_room_publication_content_type_t content_types[SKW_ROOM_CAPACITY_PUBLICATIONS] = {0}; skw_room_publication_data_fields_t publications = {0}; for (uint32_t i = 0U; i < SKW_ROOM_CAPACITY_PUBLICATIONS; i++) { publications[i].id = &publication_ids[i]; publications[i].publisher_id = &publisher_ids[i]; publications[i].content_type = &content_types[i]; } uint32_t publication_count = 0U; skw_room_data_field_t room_fields = { .publications = &publications, .publication_count = &publication_count, }; // 参加しているRoomのPublicationを取得します。 const skw_room_find_by_id_params_t room_params = SKW_ROOM_FIND_BY_ID_PARAMS_INIT( .id = s_room_id ); const skw_room_err_t list_err = skw_room_find_by_id(&room_params, &room_fields); if (list_err != SKW_ROOM_OK) { ESP_LOGE(TAG, "Failed to list the publications: %d", (int)list_err); return; } // 取得したPublicationの中から、自分以外のData PublicationをSubscribeします。 for (uint32_t i = 0U; i < publication_count; i++) { if ((strcmp(publisher_ids[i], s_member_id) != 0) && (content_types[i] == SKW_ROOM_PUBLICATION_CONTENT_TYPE_DATA)) { enqueue_subscribe(publication_ids[i]); } } }

イベントハンドラの登録

Roomで発生したイベントや、Subscribeしたデータの受信を受け取るイベントハンドラを登録します。

Embedded SDKでは、イベントの種類ごとに関数ポインタを構造体に設定し、skw_room_register_handlers()skw_room_subscription_register_handlers() で登録します。 不要なイベントのフィールドは NULL のままにできます。

on_stream_published は、いずれかのMemberが新しくPublishを開始したとき(Publicationが作られたとき)に呼ばれます。 このイベントハンドラを使って、他のMemberがデータのPublishを始めたときに自動的にSubscribeするよう実装しています。 前述のとおり、イベントハンドラ内からは直接Subscribeできないため、キューに積むだけにしています。

on_data_received は、SubscribeしているDataStreamでデータを受信したときに呼ばれます。 このクイックスタートでは、受信したデータをログに出力します。

// -------------------------- // イベントハンドラ // -------------------------- // Memberが参加したときに呼ばれるイベント。 static void on_member_joined(const char* room_id, const char* member_id) { (void)room_id; ESP_LOGI(TAG, "Member joined. member_id=%s", member_id); } // Memberが退出したときに呼ばれるイベント。 static void on_member_left(const char* room_id, const char* member_id) { (void)room_id; ESP_LOGI(TAG, "Member left. member_id=%s", member_id); } // StreamがPublishされたときに呼ばれるイベント。このサンプルはDataだけをSubscribeします。 static void on_stream_published(const char* room_id, const char* publication_id, const char* publisher_id, skw_room_publication_content_type_t content_type) { (void)room_id; ESP_LOGI(TAG, "Stream published. publication_id=%s publisher_id=%s content_type=%d", publication_id, publisher_id, (int)content_type); if ((strcmp(publisher_id, s_member_id) != 0) && (content_type == SKW_ROOM_PUBLICATION_CONTENT_TYPE_DATA)) { enqueue_subscribe(publication_id); } } // DataStreamでデータを受信したときに呼ばれるイベント。 static void on_data_received(const char* subscription_id, const char* publication_id, const uint8_t* data, size_t length) { (void)subscription_id; ESP_LOGI(TAG, "Received data. publication_id=%s data=%.*s length=%u bytes", publication_id, (int)length, (const char*)data, (unsigned int)length); } // 上のイベントハンドラをSkyWay Embedded SDKへ登録する関数。 static bool register_handlers(void) { const skw_room_handlers_t room_handlers = { .on_member_joined = on_member_joined, .on_member_left = on_member_left, .on_stream_published = on_stream_published, }; if (skw_room_register_handlers(&room_handlers) != SKW_ROOM_OK) { ESP_LOGE(TAG, "Failed to register the room handlers."); return false; } const skw_room_subscription_handlers_t subscription_handlers = { .on_data = on_data_received, }; if (skw_room_subscription_register_handlers(&subscription_handlers) != SKW_ROOM_SUBSCRIPTION_OK) { ESP_LOGE(TAG, "Failed to register the subscription handlers."); return false; } return true; }

注意 各イベントハンドラ内では直接、SkyWayの操作や処理をブロックするような処理は行わないでください。 そのような処理を行う場合は他のタスクへ処理を移譲してください。

エントリポイント

ESP-IDFでは、アプリケーションのエントリポイントは app_main() です。 Embedded SDKの関数はスタックを多く使用するため、app_main() からは十分なスタックサイズを持つ専用のタスク(app_task)を作成し、その中でSkyWayを操作します。

app_task では、これまでに説明した関数を以下の順番で呼び出します。

  1. ネットワークへ接続する
  2. SkyWay Contextを作成する
  3. Subscribeを行うタスクを開始し、イベントハンドラを登録する
  4. Roomに参加する
  5. VideoStream・AudioStream・DataStreamをPublishする
  6. 映像・音声の送信タスクを開始する
  7. すでにPublishされているDataStreamをSubscribeする
  8. メインループで、1秒間隔でデータを送信する
// -------------------------- // エントリポイント // -------------------------- // 準備を済ませ、メインループでデータ送信を繰り返すタスク。 static void app_task(void* pvParameters) { (void)pvParameters; // ネットワークへの接続を開始します。 if (!network_start()) { ESP_LOGE(TAG, "Stopped. Failed to connect to the network."); vTaskDelete(NULL); } // SkyWay Embedded SDKを初期化します。 if (!skyway_start()) { ESP_LOGE(TAG, "Stopped. Failed to start SkyWay."); vTaskDelete(NULL); } // Subscribeを行うタスクを開始します。 if (!start_subscribe_task()) { ESP_LOGE(TAG, "Stopped. Failed to start the subscribe task."); vTaskDelete(NULL); } // イベントハンドラをSkyWay Embedded SDKへ登録します。 if (!register_handlers()) { ESP_LOGE(TAG, "Stopped. Failed to register the handlers."); vTaskDelete(NULL); } // ルームに参加します。 if (!room_join()) { ESP_LOGE(TAG, "Stopped. Failed to join the room."); vTaskDelete(NULL); } // 映像・音声・データをPublishします。 if (!publish_stream(SKW_ROOM_PUBLICATION_CONTENT_TYPE_VIDEO, &s_video_publication_id)) { ESP_LOGE(TAG, "Stopped. Failed to publish the video."); vTaskDelete(NULL); } if (!publish_stream(SKW_ROOM_PUBLICATION_CONTENT_TYPE_AUDIO, &s_audio_publication_id)) { ESP_LOGE(TAG, "Stopped. Failed to publish the audio."); vTaskDelete(NULL); } if (!publish_stream(SKW_ROOM_PUBLICATION_CONTENT_TYPE_DATA, &s_data_publication_id)) { ESP_LOGE(TAG, "Stopped. Failed to publish the data."); vTaskDelete(NULL); } // 映像と音声は専用のタスクで送り続けます。データはこのタスクのループで送ります。 if ((xTaskCreate(&media_send_task, s_video_sender.task_name, SEND_TASK_STACK_SIZE, (void*)&s_video_sender, SEND_TASK_PRIORITY, NULL) != pdPASS) || (xTaskCreate(&media_send_task, s_audio_sender.task_name, SEND_TASK_STACK_SIZE, (void*)&s_audio_sender, SEND_TASK_PRIORITY, NULL) != pdPASS)) { ESP_LOGE(TAG, "Stopped. Failed to create the send tasks."); vTaskDelete(NULL); } // 参加した時点で存在するPublicationもSubscribeします。 subscribe_existing_publications(); uint32_t loop_count = 0U; while (true) { // データメッセージを送信します。 send_data_message(s_data_publication_id, loop_count); loop_count++; vTaskDelay(pdMS_TO_TICKS(APP_LOOP_INTERVAL_MS)); } } void app_main(void) { // SkyWay Embedded SDKの動作はInfoログで追えます。 esp_log_level_set(SKW_LOGGER_TAG, ESP_LOG_INFO); if (xTaskCreate(&app_task, "app", APP_TASK_STACK_SIZE, NULL, APP_TASK_PRIORITY, NULL) != pdPASS) { ESP_LOGE(TAG, "Failed to create the app task."); } }

自分のプロジェクトへ組み込む場合

クイックスタートを参考に、独自のESP-IDFプロジェクトでEmbedded SDKを利用する場合は、以下の設定が必要です。

  • リポジトリの include/ と、バイナリを配置した libs/skyway.cmake をプロジェクトにコピーする。skyway.cmake 内でヘッダーの場所を指定している SKYWAY_INCLUDE_DIR は、コピー先の配置に合わせて変更してください
  • アプリケーションのコンポーネントの CMakeLists.txt で、REQUIRESesp_http_clientesp_netifesp_websocket_clientmbedtlsesp_timerpthread を追加し、idf_component_register() の後で skyway.cmakeinclude() する。クイックスタートの main/CMakeLists.txt を参考にしてください
  • idf_component.ymlespressif/esp_websocket_client の依存をバージョン 1.8.0 で追加する。バージョンは変更しないでください
  • クイックスタートの sdkconfig.defaults に含まれる設定をプロジェクトの sdkconfig.defaults に追加する。FreeRTOS関連の設定はバイナリ内部のデータ構造に影響するため、値を変更しないでください
  • アプリケーション領域が十分な大きさ(3MB程度)のパーティションテーブルを用意する。クイックスタートの partitions.csv を参考にしてください
  • ESP32-P4を利用する場合は、クイックスタートの main/idf_component.yml を参考に、Wi-Fiコプロセッサ用のコンポーネントを依存に追加する

クイックスタートのソースコードは、Embedded SDKのGitHubリポジトリexamples/quickstart ディレクトリで公開しています。

次のステップ

このクイックスタートでは、あらかじめエンコードした映像・音声を埋め込んで送信しました。 実際のカメラやマイクの映像・音声を送るには、アプリケーション側でエンコードし、Embedded SDKへフレームを渡します。Espressifが公開しているコンポーネントを組み合わせることで実現できます。 また、Ethernetなど、Wi-Fi以外の通信方式にも対応できます。

カメラの映像をPublishする

  • カメラの取り込みには espressif/esp_video を利用できます。Linuxと同じV4L2のAPIでフレームを取得でき、ESP32-P4ではMIPI-CSIとDVP、ESP32-S3ではDVPのカメラに対応しています。出力のピクセルフォーマットはYUV420に設定します。
  • H.264のエンコードには espressif/esp_h264 を利用できます。ESP32-P4はハードウェアエンコーダ(esp_h264_enc_hw_new())を持ち、1080pを30fps以上でエンコードできます。ESP32-S3はソフトウェアエンコーダ(esp_h264_enc_sw_new())のみで、320x240程度の解像度で十数fpsが目安です。いずれもBaselineプロファイルで出力されるため、Embedded SDKの要件を満たします。
  • エンコーダの設定は、GOPをフレームレートと同じ値(1秒ごとにキーフレーム)にし、解像度は16の倍数、ビットレートは1Mbps程度が目安です。エンコード結果はSPSとPPSを含むAnnex B形式で出力されるので、そのまま skw_room_publication_send_video_frame() に渡せます。pts にはフレームを取り込んだ時刻(ミリ秒)を指定します。
  • 相手からキーフレームを要求されたときは、on_keyframe_request の中でハードウェアエンコーダの esp_h264_enc_force_idr() を呼び出してキーフレームを生成します。ソフトウェアエンコーダにはこの機能がないため、GOPを短めに設定してください。

注意 H.264(AVC)は特許プールで管理されているコーデックです。H.264エンコーダを組み込んだ製品を頒布する場合、Via LA(旧MPEG LA)のAVC特許ポートフォリオライセンスなど、特許使用料(パテントフィー)の対象になることがあります。 esp_h264 はApache License 2.0で提供されていますが、このライセンスにはH.264の特許に関する許諾は含まれていません。ハードウェアエンコーダを利用する場合も同様です。 製品化の際は、特許使用料の要否について事前に確認してください。なお、音声に使用するOpusはロイヤリティフリーで利用できます。

マイクの音声をPublishする

  • マイクからのPCMデータの取得には espressif/esp_codec_dev を利用できます。開発ボードのBSP(Board Support Package)が用意するマイクの初期化関数でデバイスを開き、esp_codec_dev_read() で1フレーム分のPCMデータ(16bit)を読み出します。
  • Opusのエンコードには espressif/esp_audio_codec を利用できます。esp_opus_enc_open() で開き、esp_opus_enc_process() でPCMデータをエンコードします。設定は、サンプリングレート48kHz、フレーム長60ms、VoIPモード、ビットレート48kbps程度で動作を確認しています。マイクが1チャンネルの場合、1チャンネルのままエンコードして送信できます。
  • Opusのエンコードはスタックを多く使うため、エンコードを行うタスクのスタックサイズは40KB以上を確保してください。
  • pts は起動からの経過時間(ミリ秒)から算出します。フレーム長を積算すると、取りこぼしが起きたときに実時間からずれるためです。
  • 送信タスクの優先度は、このクイックスタートの映像・音声の送信タスクと同じ値(5)にしてください。Embedded SDKの内部タスクより低くすると、送信が待たされることがあります。

映像と音声をSubscribeする

他のMemberがPublishしている映像・音声を受信する場合は、skw_room_subscription_register_handlers()on_video_frameon_audio_frame を登録します。どちらもエンコード済みのフレームがそのまま渡されるため、デコードと出力はアプリケーション側で行います。

  • ハンドラはEmbedded SDKの内部タスクから呼ばれ、渡されるフレームデータは呼び出し中のみ有効です。ハンドラ内ではフレームを自分のバッファへコピーして別のタスクへ渡すだけにし、デコードや出力はそのタスクで行ってください。ハンドラ内からSubscribeやUnsubscribeを呼び出すことはできません。
  • 映像のSubscribeは試験的機能です。skw_context_setup() のオプションで experimental.enable_video_subscribetrue にすると有効になります。
  • 受信できる映像フレームは1フレームあたり約690KB(1280x720を基準にした上限)までで、超えたフレームは破棄されます。

映像の再生には esp_h264 のソフトウェアデコーダを利用できます。esp_h264_dec_sw_new() で出力フォーマットをI420にして開き、esp_h264_dec_process() でデコードした画像を、LVGLのキャンバスなどでディスプレイへ描画します。 デコードが追いつかずフレームを捨てた場合は、次のキーフレーム(SPSまたはIDRを含むフレーム)が来るまでデコーダへ渡さないようにしてください。Pフレームは直前のフレームに依存するため、抜けたまま渡すと次のキーフレームまで映像が壊れ続けます。

音声の再生には esp_audio_codec のOpusデコーダを利用できます。on_audio_frame で受け取ったフレームをキューに積み、再生タスクで esp_opus_dec_decode() によりPCMデータへ戻し、esp_codec_dev_write() でスピーカーへ出力します。再生が追いつかない場合は、キューの最も古いフレームを捨てて遅延の蓄積を防ぎます。スピーカーがモノラルの場合は、デコード結果の1チャンネルだけを出力してください。

Wi-Fi以外のインターフェースで接続する

Embedded SDKはESP-IDFのネットワークインターフェース(esp_netif)上のTCP/UDP通信だけを利用しており、Wi-Fiには依存していません。 そのため、network.c のWi-Fi接続処理をEthernetやSIMの接続処理に置き換えることで、その方式の通信に対応することができます。 IPアドレスの取得後にSNTPで時刻を合わせる手順は、Wi-Fiの場合と同じです。

Ethernetで接続する

  • ESP32-P4はEthernet MAC(EMAC)を内蔵しているため、RMII接続のPHYチップを備えたボードであれば、ESP-IDFの esp_eth でそのまま接続できます。
  • ESP32-S3はEMACを内蔵していないため、SPI接続のEthernetモジュールを esp_eth のSPI Ethernetドライバで利用します。
  • Ethernetの接続処理は、ESP-IDFのEthernetドライバのドキュメント(ESP32-S3ESP32-P4)を参考にしてください。

SIMで接続する

  • SIMを挿したLTEモジュールで携帯回線に接続するには、Espressifが公開している espressif/esp_modem を利用できます。ATコマンドとPPPに対応したGSM/LTEモジュールにUARTで接続し、PPPで確立した回線を esp_netif のネットワークインターフェースとして扱えます。一覧にないモジュールも、汎用のATコマンド実装を継承して対応できます。
  • 映像・音声をリアルタイムに送受信するため、回線の帯域と速度が十分である必要があります。
  • 実際の通信速度は、回線だけでなくESP32とLTEモジュールをつなぐUARTのボーレートなどによっても制限されます。映像は帯域を多く消費するため注意してください。
  • 携帯回線は通信事業者のNAT配下になるため、相手と直接P2P接続できず、TURNサーバーを経由した通信になることがあります。

ぜひ、Embedded SDKを活用して、さまざまなユースケースを実現してみてください。