Azure Communication Services のクライアント ライブラリを理解しよう (現状自分が把握している部分の整理)
GA した Azure Communication Services を使えば、チャットや音声通話やビデオ通話がアプリに簡単に組み込める!ということで気になったので触ってみました。
触ってみた感じ、JavaScript の SDK のクラス構造とかを整理したほうが理解しやすいなと思ったので現段階で自分が把握している部分だけですが纏めてみました。
前提として通話系のライブラリですが現時点で 1.0.1-beta.1 でした。なので beta が取れるタイミングで少し変わるところがあるかもしれません。
ユーザーとトークン
Azure Communication Services はユーザー管理機能を持ってます。
といっても凄く簡易的なものなので、ちゃんとアプリにユーザー管理機能を付けたい場合は Azure Communication Services のユーザー ID と自前のユーザー管理の ID を紐づけるといったような対応が必要です。
ユーザーを作ったり、ユーザーがチャットや音声通話を開始するためにはアクセストークンが必要になります。ユーザーを作ったりトークンの払い出しはサーバーサイドでやることになるケースが殆どだと思います。これらの操作を行うには、Azure Communication Services のアクセスキーが必要になったりするのでクライアントに持たせるには怖い情報ですね。
これには以下のパッケージを使います。
- @azure/communication-common
- @azure/communication-identity
コード的には以下のようになります。
const client = new CommunicationIdentityClient("Azure Communication Services への接続文字列");
// ユーザーを作る
const user = await client.createUser();
// ユーザーは ID を持っているだけのシンプルなデータです。自前管理しているユーザーなどがある場合には
// そのユーザーと ID の紐づけを自前で管理する必要があります。
console.log(`New user id: ${user.communicationUserId}`);
トークンの払い出しは以下のように CommunicationIdentityClient
の getToken
メソッドで行います。getToken
の第二引数がスコープで、今回はビデオ通話をしたいと思っているので voip
を指定しています。
スコープの一覧は以下のページに書いてありますが chat
か voip
です。
// トークンを生成
const { token, expiresOn } = await client.getToken(user, ["voip"]);
console.log(`有効期限: ${expiresOn}`);
console.log(token);
getToken
に渡す user
は上で作成した createUser
の戻り値か、ID がわかっているなら以下のように指定することも出来ます。
const { token, expiresOn } = await client.getToken({ communicationUserId: 'ユーザー ID' }, ["voip"]);
ビデオ通話の開始
トークンをクライアント側のアプリケーションで取得できたら通話機能を使う準備が出来ました。
多分、トークン発行してくれる Web API かなんかを作って、そこから取得することになります。クライアント側のアプリケーションでは以下のパッケージを使用します。
- @azure/communication-calling
- @azure/communication-common
@azure/communication-calling
に含まれている CallClient
というクラスが通話や通話で使用するデバイスを管理するクラスになります。
CallClient
自体には通話をするための機能は持っていなくて、CallClient
からトークンを使って生成する CallAgent
クラスが実際の通話関連の機能を担当しています。
デバイス管理も CallClient
から DeviceManager
というクラスが取得できて、そこで管理することが出来ます。
デバイスの管理
デバイスマネージャーは CallClient
の getDeviceManager
メソッドで取得できます。戻り値は Promise<DeviceManager>
なので await
をして DeviceManager
のインスタンスを取得します。
const callClient = new CallClient(); // CallClient は、ただ new するだけ
const deviceManager = await callClient.getDeviceManager(); // DevicdManager ください
CallClient
と DeviceManager
のインスタンスはアプリ内で 1 つが基本になると思います。
DeviceManager
からデバイスへのアクセス権限を要求するには askDevicePermission
メソッドを使用します。
const { audio, video } = await this.deviceManager.askDevicePermission({ audio: true, video: true });
引数で、オーディオとビデオの権限を要求するかどうかを指定して戻り値は、要求されたかどうかが boolean 型で帰ってきます。ちゃんとユーザーに拒否されたときのフォローも出来るようなロジックにしておく必要があります。
一度ユーザーが拒否すると、このメソッドを呼び出してもユーザーにプロンプトは表示されないのでブラウザーの設定画面に行って許可してもらう必要がありそうな気がします。
デバイスの列挙と使用デバイスの設定
デバイスには 3 種類あります。スピーカー、マイク、カメラです。
それぞれ DeviceManager
の getSpeakers
, getMicrophones
, getCameras
の 3 種類です。戻り値は Promise
なので await することで、それぞれのデバイスの情報が取得できます。
getSpeakers
と getMicrophones
の戻り値は AudioDeviceInfo
の配列で以下のような定義になっています。
export declare interface AudioDeviceInfo {
/**
* Get the name of this video device.
*/
readonly name: string;
/**
* Get Id of this video device.
*/
readonly id: string;
/**
* Is this the systems default audio device
*/
readonly isSystemDefault: boolean;
/**
* Get this audio device type
*/
readonly deviceType: AudioDeviceType;
}
* Type of an audio device
* @public
*/
export declare type AudioDeviceType = 'Microphone' | 'Speaker' | 'CompositeAudioDevice';
通話で使用するスピーカーとマイクは DeviceManager
の selectSpeaker
と selectMicrophone
メソッドで設定します。
引数は AudioDeviceInfo
になります。現在選択されているデバイスは selectedSpeaker
と selectedMicrophone
で確認できます。
デバイスは、割とつけたり外したりとかが激しい (Bluetooth デバイスだと特にね…) ので、デバイスに変更があった場合のイベントも DeviceManager
に定義されています。
/**
* Subscribe function for videoDevicesUpdated event
* @param event - event name
* @param listener - callback fn that will be called when value of this property will change
*/
on(event: 'videoDevicesUpdated', listener: CollectionUpdatedEvent<VideoDeviceInfo>): void;
/**
* Subscribe function for audioDevicesUpdated event
* @param event - event name
* @param listener - callback fn that will be called when value of this property will change
*/
on(event: 'audioDevicesUpdated', listener: CollectionUpdatedEvent<AudioDeviceInfo>): void;
/**
* Subscribe function for selectedMicrophoneChanged event
* @param event - event name
* @param listener - callback fn that will be called when value of this property will change
*/
on(event: 'selectedMicrophoneChanged', listener: PropertyChangedEvent): void;
/**
* Subscribe function for selectedSpeakerChanged event
* @param event - event name
* @param listener - callback fn that will be called when value of this property will change
*/
on(event: 'selectedSpeakerChanged', listener: PropertyChangedEvent): void;
CollectionUpdatedEvent
は added
と removed
で追加、削除された要素が判別出来るようになっているので、それを使って最新のデバイスの状況を確認できます。
/**
* Event that a collection of objects has been updated
*/
export declare type CollectionUpdatedEvent<T> = (args: {
added: T[];
removed: T[];
}) => void;
実際にハンドリングする場合は以下のような感じのコードになります。
deviceManager.on('audioDevicesUpdated', args => {
for (const device of args.added) {
// 追加されたデバイス
}
for (const device of args.removed) {
// 追加されたデバイス
}
});
カメラを取得する getCameras
は VideoDeviceInfo
の配列が返ってきます。VideoDeviceInfo
は以下のような定義になっています。
export declare interface VideoDeviceInfo {
/**
* Get the name of this video device.
*/
readonly name: string;
/**
* Get Id of this video device.
*/
readonly id: string;
/**
* Get this video device type
*/
readonly deviceType: VideoDeviceType;
}
/**
* Type of a video device
* @public
*/
export declare type VideoDeviceType = 'Unknown' | 'UsbCamera' | 'CaptureAdapter' | 'Virtual';
スピーカーとマイクは DeviceManager
にセットしておしまいなのですが、カメラだけ扱いが少し異なります。カメラは、通話に対して、どのカメラを使うかという形で設定します。なのでカメラの設定については通話を始める部分の解説で扱おうと思います。
通話をする
Azure Communication Services では 1 対 1 の通話と、グループ通話の機能を利用することが可能です。
通話をするには CallAgent
を使用する必要があります。
CallAgent
は CallClient
からトークンを利用して作成します。基本的にはサーバーサイドで生成してもらったトークンの有効期限が 24 時間くらいあるので 24 時間立ち上げっぱなしのようなアプリでない限りは以下のように何も考えずにトークンを使う形でも大丈夫かなと思います。
// token にアクセストークン
const tokenCredential = new AzureCommunicationTokenCredential(token);
const callAgent = await callClient.createCallAgent(tokenCredential);
トークンのリフレッシュにも対応していて以下のドキュメントに記載があります。
公式サンプルでは、以下の部分でトークンのリフレッシュ機能を実現しています。
AzureCommunicationTokenCredential
の生成処理
リフレッシュトークン取得処理を呼び出している部分
リフレッシュトークンを生成しているサーバーサイドの処理
通話への参加方法
通話への参加方法はいくつかありますが、大まかに以下の 3 つのパターンを押さえておけばいいと思います。
- 特定のユーザーに対して電話をかける
- 特定のグループ通話に参加する
- 別のユーザーからの電話を受ける
1 つずつ見てみましょう。
特定のユーザーに対して電話をかける
ユーザーに電話をかけるには、電話をかける先のユーザーの ID が必要になります。ID は CommunicationIdentityClient
で createUser
したときに返されるユーザーの communicationUserId
の値です。あとはビデオ通話をする場合は DeviceManager
の getCameras
メソッドで返されるものの中から使いたいものを渡してやります。
戻り値は Call
という型のインスタンスです。
const call = callAgent.startCall(
[{ communicationUserId: remoteId }], // remoteId が電話をかける相手先ユーザーの ID
{
videoOptions: {
localVideoStreams: [
new LocalVideoStream(videoDeviceInfo), // 使いたいカメラの VideoDeviceInfo を渡す
]
}
});
実際のカメラ映像のレンダリングは、通話への参加方法にかかわらず共通なので後でまとめて紹介したいと思います。通話を終了するには Call
クラスの hangUp
を呼べば抜けられます。
特定のグループ通話に参加する
グループ通話に参加するには CallAgent
の join
メソッドで行います。戻り値は startCall
と同じで Call
型のインスタンスです。
グループ自体の管理機能は Azure Communication Services には無いので、グループの管理は自前アプリで行う必要があります。指定した groupId のグループがない場合は一人だけのグループ通話になって、誰かが入ってくるのを待つ状態になります。
const call = this.callAgent.join({ groupId: "xxxxxxxxx-xxxx-xxxx-xxxxxxxxxxx" });
トークンの更新のところで紹介した公式サンプルでは、この groupId 込みの URL を何らかの方法で通話したい人とシェアしてグループ通話を実現していました。
別のユーザーからの電話を受ける
人から通話がかかってくると CallAgent
の incomingCall
イベントが発生するので、そこのイベント引数の incomingCall.accept
メソッドを呼ぶことで受付が可能です。
callAgent.on('incomingCall', async e => {
const call = await e.incomingCall.accept({
videoOptions: { localVideoStreams: [localVideoStream] } // LocalVideoStream を渡すことでカメラがオンの状態で通話を受けることが出来る
});
});
自分のビデオを画面に表示したい
VideoDeviceInfo
から LocalVideoStream
を作ります。LocalVideoStream
を使って VideoStreamRenderer
を作ってレンダリングを行います。
VideoStreamRenderer
の createView
メソッドで VideoStreamRendererView
を作って target
プロパティで DOM に追加するための HTMLElement
が取得できるので任意の要素に appencChild
あたりで追加してやれば、そこにカメラの映像が表示されます。
VideoStreamRenderer
に対して dispose
を呼び出すとビデオが止まります。
// VideoDeviceInfo から LocalVideoStream を作る
const localVideoStream = new LocalVideoStream(videoDeviceInfo);
// LocalVideoStream から VideoStreamRenderer を作る
const videoRenderer = new VideoStreamRenderer(localVideoStream);
// createView をした結果の target がビデオがレンダリングされる HTMLElement
const videoRendererView = await this.videoRenderer.createView();
const element = videoRendererView.target;
// こんな感じで適当な要素に append すると画面に表示される
document.getElementById("someDivTagId").appendChild(element);
// ----------------------------------------
// ビデオのレンダリングを止める場合は以下のように dispose を呼べばレンダラーを破棄出来る
videoRenderView.dispose(); // view を破棄
videoRenderer.dispose(); // renderer を破棄
// ----------------------------------------
// ビデオのデバイスを変えたい場合は LocalVideoStream の switchSource で切替
await localVideoStream.switchSource(anotherVideoDeviceInfo);
相手のビデオを画面に表示したい
ちょっと複雑です。相手のビデオのオンオフへの対応やユーザーが追加されたときとか考えないといけません…。
グループ通話への対応は、まだ確認中なので後で記事を更新するかもしれません。
ここでは 1 対 1 通話の場合を考えます。1 対 1 通話の場合は Call
オブジェクトの remoteParticipants
を見ると通話相手が入っているのでそこに対してリモートのビデオストリームを取得してレンダリングします。ビデオのオン・オフとかがあるので isAvailabilityChanged
イベントのハンドリングをして有効・無効にも対応します…。結構難しい。
// remoteRenderer という通話相手用の VideoStreamRender という変数は別で定義されている想定のコード
// リモートのビデオストリームを秒がする処理
const createVideoStreamRenderer = async (stream: RemoteVideoStream) => {
if (stream.isAvailable) {
// 有効ならレンダラーを作って View を作って表示する
remoteRenderer?.dispose();
remoteRenderer = new VideoStreamRenderer(stream);
const view = await remoteRenderer.createView();
const placeHolderForView = document.getElementById("someId");
placeHolderForView.innerHTML = "";
placeHolderForView.appendChild(view);
} else {
// 有効じゃないなら消す
remoteRenderer?.dispose();
const placeHolderForView = document.getElementById("someId");
placeHolderForView.innerHTML = "";
}
};
// 通話相手を remotePariticipants で取得できるので、その数だけループ
for(const remote of call.remoteParticipants) {
// 通話相手のビデオストリームに対して有効化・無効化のイベントをハンドリングしつつ、とりあえず初回の表示処理をやる
for (const remoteVideoStream of remote.videoStreams) {
remoteVideoStream.on('isAvailableChanged', async() => {
createVideoStreamRenderer(remoteVideoStream);
});
createVideoStreamRenderer(remoteVideoStream);
}
}
グループチャットの場合は Call
に対して remoteParticipantsUpdated
イベントがあるので、ここでリモートの人のビデオストリームに対応する処理を書くことになると思います。地味に複雑ですね…。
this.call.on('remoteParticipantsUpdated', args => {
for(const addedUser of args.added) {
// ユーザー追加に対応
}
for (const removedUser of args.removed) {
// ユーザー削除の場合に対応
}
});
まとめ
何日かかけてドキュメントとサンプルを見てみた結果わかったことを書いてみました。
まだわかってないことが沢山あります。スクリーンシェアとかグループ通話にユーザーを追加とかマイクのミュートとかのメソッドが Call
クラスにありますが、実際に呼んで試すところまでは出来ていません。こんな感じのメソッドがあります。(使い方はシグネチャから想像はできますね)
/**
* Mute local microphone.
*/
mute(): Promise<void>;
/**
* Unmute local microphone.
*/
unmute(): Promise<void>;
/**
* Send DTMF tone.
*/
sendDtmf(dtmfTone: DtmfTone): Promise<void>;
/**
* Start sending video stream in the call.
* @param localVideoStream - Represents a local video stream and takes a camera in constructor.
*/
startVideo(localVideoStream: LocalVideoStream): Promise<void>;
/**
* Stop local video, pass localVideoStream you got from call.startVideo() API call.
* @param localVideoStream - The local video stream to stop streaming.
*/
stopVideo(localVideoStream: LocalVideoStream): Promise<void>;
/**
* Add a participant to this Call.
* @param identifier - The identifier of the participant to add.
* @param options - Additional options for managing the PSTN call. For example, setting the Caller Id phone number in a PSTN call.
* @returns The RemoteParticipant object associated with the successfully added participant.
*/
addParticipant(identifier: CommunicationUserIdentifier | MicrosoftTeamsUserIdentifier): RemoteParticipant;
addParticipant(identifier: PhoneNumberIdentifier, options?: AddPhoneNumberOptions): RemoteParticipant;
/**
* Remove a participant from this Call.
* @param identifier - The identifier of the participant to remove.
* @param options - options
*/
removeParticipant(identifier: CommunicationUserIdentifier | PhoneNumberIdentifier | MicrosoftTeamsUserIdentifier | UnknownIdentifier): Promise<void>;
/**
* Put this Call on hold.
*/
hold(): Promise<void>;
/**
* Resume this Call.
*/
resume(): Promise<void>;
/**
* Start local screen sharing, browser handles screen/window enumeration and selection.
*/
startScreenSharing(): Promise<void>;
/**
* Stop local screen sharing.
*/
stopScreenSharing(): Promise<void>;
Discussion