2024年12月04日
Agoraで独自のオンラインレッスンアプリを作る
※この投稿は、Agoraの日本代理店であるブイキューブが、Agoraブログを翻訳した記事です。
この記事では、自前のオンラインレッスンアプリの作成方法を紹介いたします。教師としてレッスン開催、または生徒として授業料の支払いやレッスン受講などをこのアプリにて完結させることができます。すべてのビデオレッスンはプラットフォーム上で実施されるので、認証、ユーザー管理や支払い機能の他に多様なリアルタイムの機能も含まれる包括的なアプリになります。
目次
[非表示]使用ツール一覧
このプロジェクトに以下のツールを使用します。
- Flutter
- Agora
- Firebase
- Riverpod
- Apple Payのpayパッケージ
- 他のFlutterパッケージ(image_picker, video_player, intl)
- PythonとFlaskで構築されたカスタムバックエンド
Flutterアプリ向けのソースコードはこちら、バックエンドのコードはこちらをご参照ください。この記事では、アプリを構築するにあたって一般的な構造について説明します。それに加えて、Agoraの録画(Cloud Recording)および文字起こし(Real-Time Transcription)機能を紹介します。
概要
このtutor(オンラインレッスン)アプリにおいて、ユーザーのログインまたはサインアップはFirebase認証を利用します。RiverpodとFirebase Cloud Firestoreを使ってユーザー状態の管理や更新を行います。ユーザー設定で、教師または生徒のアカウントを切り替えることができます。どちらのアカウントでも支払いやレッスンに参加することができますが、教師アカウントにはレッスンを開催する権限も付与されます。
レッスンが開始されると、ユーザーはAgoraの文字起こし機能で音声をリアルタイムでテキスト化した文字を見れます。また、自分のカメラを調整することもできます。
ファイルの構成
lib
├── models
│ ├── recording
│ ├── session
│ └── user
├── pages
│ ├── home
│ ├── recordings
│ ├── class
│ ├── create
│ ├── settings
│ ├── signin
│ └── signup
├── protobuf
├── providers
│ └── user_provider
├── consts
└── main
アーキテクチャ
ユーザーフロー
アプリの初期画面
ユーザーがこちらでログインやサインアップを行う
教師と生徒のホーム画面
ナビゲーションと設定画面
オンラインレッスンの風景
Apple Pay
録画
Agoraの概要
このtutor(オンラインレッスンアプリ)の主な特徴としては、教師と生徒がオンライン形式でビデオ通話レッスンの実施を可能にすることです。これに必要な機能はAgoraで実装します。Agoraはリアルタイムのコミュニケーションプラットフォームとなり、ビデオ通話、音声通話やライブ配信をアプリに組み込むことができます。また、このオンラインレッスンアプリでリアルタイムのコミュニケーションを行うに必要なすべての機能をサポートします。
Agoraの機能
ビデオ通話
オンラインレッスンを実現するにあたってビデオ通話機能はアプリの最も重要な部分と言えます。これについては、ビデオ通話画面の実装にagora_uikitパッケージを使っています。このパッケージにAgora SDKを組み込んでいてUIもすでにできているので、自分で作る手間が省けます。
このパッケージを使うにはAgoraのアカウントが必要です。こちらのAgoraコンソールページにサインアップしてください。Agoraのアカウントを登録してから、コンソール上でアプリ用のプロジェクトを作成しプロジェクトのAppID(※)を取得してください。このAppIDはAgora SDKに接続するためのキーとなります。
※AppIDは各プロジェクトに一意の識別子として自動的に割り当てられるものとなります。
iOSとAndroid端末にてAgora Video SDKを実行するための権限を追加しておいてください。
以下のコマンドを叩いてパッケージを追加します。
flutter pub add agora_uikit
本番環境のようなサービスに対して高いセキュリティが求められる場面には、トークン発行専用のサーバーを設置する必要があります。このサーバーとAgoraのUIキットとの紐付けを行います。トークン発行専用のサーバーについて詳しくはこちらをご覧ください。
上記のセットアップが完了すると、class.dartファイルを作成し以下のコードを追加してください。
import 'package:agora_uikit/agora_uikit.dart';
import 'package:flutter/material.dart';
class ClassCall extends StatefulWidget {
const ClassCall({Key? key}) : super(key: key);
@override
State<ClassCall> createState() => _ClassCallState();
}
class _ClassCallState extends State<ClassCall> {
final AgoraClient client = AgoraClient(
agoraConnectionData: AgoraConnectionData(
appId: "<--Add your App Id here-->",
channelName: "test",
username: "user",
tokenUrl: "<--Add your token server url here-->",
),
);
@override
void initState() {
super.initState();
initAgora();
}
void initAgora() async {
await client.initialize();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Classroom'),
),
body: SafeArea(
child: Stack(
children: [
AgoraVideoViewer(
client: client,
layoutType: Layout.floating,
),
AgoraVideoButtons(
client: client,
),
],
),
),
);
}
}
通話が終了してユーザーがチャンネルから退室する時に、スワイプで戻ったりアプリのメニューバーに戻るボタンを設置したりするようなことせずに通話終了ボタンを押すと通話画面から離れる機能を実装します。これについてまずはAppBar()ウィジェットを削除します。
次にAgoraVideoButtonsウィジェットにonDisconnect関数を追加します。ユーザーが通話終了ボタンを押すと、この関数が呼び出されます。この関数の中で、Navigator.pop(context)をコールすることでユーザーが現在の画面から離れて元の画面に戻ることができます。
こうすると、アプリのメニューバーに戻るボタンを設けることなく、ユーザーは通話終了ボタンを押すだけで元の画面に戻れます。そして、端末システム側の戻るボタンも同様の制御が必要になります。iOSとAndroid端末を利用する場合は、システムの戻るボタンをタップする、またはスワイプで元の画面に戻ることができます。このような操作を制御するためにWillPopScopeウィジェットを使います。AgoraのScaffoldウィジェットをこれに組み込むと戻るボタンのデフォルトの動作をオーバーライドできます。onWillPop関数からFuture<bool>を返します。もしこの値をtrueで返すと、戻るボタンは通常通り動作します。
falseを返せば、戻るボタンは動作しません。onWillPop関数をカスタマイズして様々な条件で元の画面を表示することもできるが、今回のユースケースでは常にfalseを返すように設定します。
録画機能
AgoraのCloud Recording(クラウド録画)機能は自前のデータベースに接続して、アプリでオンラインレッスンを録画することができます。録画ファイルはお客様のデータベースに保存されます。これにAgora独自のバックエンドサービスと、AgoraのCloud Recording機能用のRESTful APIを使います。
セキュリティの観点からAgora RESTfull API利用のためのCustomer IDとCustomer Secretをアプリに保存しないので、バックエンドサービスを利用します。このサーバーはPythonとFlaskで構築されています。コードはこちらをご参照ください。この記事では、Agora APIに関わる部分だけを紹介するので、Flaskでゼロからサーバーを構築することについてもっと知りたい場合は、この動画をおすすめします。
今回はstart-recordingとstop-recordingの関数をバックエンドサーバーに実装します。
録画開始
バックエンドがどのチャンネルで録画タスクを実行するかを分かるためにstart-recordingのエンドポイント(Endpoint)に対象チャンネルの情報を設定する必要があります。録画が開始されると、この録画はどこに保存されるかを把握できるようにユーザー(呼び出し元)にエンドポイント、SID、リソースID(resourceId)を送ります。
また、録画タスクを実行するための最初のステップとしては、Cloud Recording用のリソースを作ることです。これについてまずAgora Console上でAgora RESTfull API利用のためのCustomer IDとCustomer Secretを生成して、この二つの情報でCloud Recording用のリソースを作るための認証情報を生成します。
Cloud Recording用のリソースの生成に必要な認証情報を取得するにあたって、以下のコードでCustomer IDとCustomer Secretをエンコーディングする必要があります。
def generate_credential():
# Generate encoded token based on customer key and secret
credentials = CUSTOMER_KEY + ":" + CUSTOMER_SECRET
base64_credentials = base64.b64encode(credentials.encode("utf8"))
credential = base64_credentials.decode("utf8")
return credential
それから、この認証情報でacquireを実行してCloud Recording用のリソースを作成します。このメソッドにてresourceId(録画実行ID)が発行されます。このresourceIdで録画タスク(start Cloud Recording)を実行します。
def generate_resource(channel):
payload = {
"cname": channel,
"uid": str(UID),
"clientRequest": {}
}
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
headers['Access-Control-Allow-Origin'] = '*'
url = f"https://api.agora.io/v1/apps/{APP_ID}/cloud_recording/acquire"
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
resourceId = data["resourceId"]
return resourceId
これで録画を開始する準備ができました。前述したセットアップの他に、必要な設定について詳しくはこちらをご覧ください。
今回の録画設定は以下を示します。
- 今回は、mixモード(Composite recording mode:複合モード)で録画するので、各ユーザ(UID)の映像を結合して一つのファイルにまとめられます。
- 録画ファイルをAWS上のagoraフォルダに保存します。
- mp4形式で録画ファイルを保存します。
def start_cloud_recording(channel):
resource_id = generate_resource(channel)
url = f"https://api.agora.io/v1/apps/{APP_ID}/cloud_recording/resourceid/{resource_id}/mode/mix/start"
payload = {
"cname": channel,
"uid": str(UID),
"clientRequest": {
"token": TEMP_TOKEN,
"recordingConfig": {
"maxIdleTime": 3,
},
"storageConfig": {
"secretKey": SECRET_KEY,
"vendor": 1, # 1 is for AWS
"region": 1,
"bucket": BUCKET_NAME,
"accessKey": ACCESS_KEY,
"fileNamePrefix": [
"agora",
]
},
"recordingFileConfig": {
"avFileType": [
"hls",
"mp4"
]
},
},
}
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
headers['Access-Control-Allow-Origin'] = '*'
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
sid = data["sid"]
return resource_id, sid
ここまでバックエンドサービスの準備が完了したので、アプリ側にAgoraのCloud Recording機能を実装するステップに入ります。まずはAgoraClientのcloudRecordingUrl引数にリンクを追加します。そして、AgoraVideoButtonsウィジェットにcloudRecordingEnabled: trueを設定します。
録画終了
録画を終了するにあたってバックエンドに他の関数を実装する必要もあるが、これはUIキットでカバーされているのでフロントエンド側で特に作業なしで問題ありません。あとは、/stop-recording/<--Channel Name-->/<--SID-->/<--Resource ID-->のフォーマットに従うエンドポイントが必要なだけです。
ここで重要なのは、当該録画の情報、特にmp4ファイルのリンクをエンドユーザーに返すことです。
def stop_cloud_recording(channel, resource_id, sid):
url = f"https://api.agora.io/v1/apps/{APP_ID}/cloud_recording/resourceid/{resource_id}/sid/{sid}/mode/mix/stop"
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json;charset=utf-8'
headers['Access-Control-Allow-Origin'] = '*'
payload = {
"cname": channel,
"uid": str(UID),
"clientRequest": {
}
}
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
resource_id = data['resourceId']
sid = data['sid']
server_response = data['serverResponse']
mp4_link = server_response['fileList'][0]['fileName']
m3u8_link = server_response['fileList'][1]['fileName']
formatted_data = {'resource_id': resource_id, 'sid': sid,
'server_response': server_response, 'mp4_link': mp4_link, 'm3u8_link': m3u8_link}
return formatted_data
アーカイブしたレッスンの録画ファイルを確認
ビデオ通話が終了後にいつでも録画が見れるには、まずファイルの保管場所(パス)のリンクを保存しておく必要があります。このアプリがFirebase認証とFirestoreストレージでビルドされているので、今回はFirestoreに録画のリンクを保存します。
AgoraClientのcloudRecordingCallbackの関数は録画のmp4リンクを返してくれます。
まずは、録画について我々に必要なすべての情報を保持するデータクラスを作成します。
class Recording {
final String url;
final String sessionId;
final String date;
Recording({
required this.url,
required this.sessionId,
required this.date,
});
}
次に、このアプリはUserProviderでビルドされているので、StateNotifierに録画を保存するための関数をセットアップします。
Future<void> storeRecording(Recording recording) {
return _firestore
.collection("users")
.doc(state.id)
.collection("recordings")
.add(
recording.toMap(),
);
}
そして、cloudRecordingCallbackでこの関数を呼び出します。
ref.read(userProvider.notifier).storeRecording(
Recording(
url: mp4,
sessionId: widget.sessionId,
date:
"${DateFormat.yMMMMd('en_US').format(DateTime.now())}\n${DateFormat("hh:mm a").format(DateTime.now())}",
),
);
すべての録画をリスト化してユーザーに送ります。ユーザーは一覧から録画をクリックして内容を再生することができます。この実装方法としては、必要なすべての情報を取得するためにuser providerに他の関数を追加します。
Future<List<Recording>> getRecordings() async {
QuerySnapshot response = await _firestore
.collection("users")
.doc(state.id)
.collection("recordings")
.get();
List<Recording> recordings = [];
for (DocumentSnapshot snapshot in response.docs) {
recordings
.add(Recording.fromMap(snapshot.data() as Map<String, dynamic>));
}
return recordings;
}
次に、Drawerウィジェットからアクセスできる録画リスト(ListView)を表示します。
class RecordingList extends ConsumerWidget {
const RecordingList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Recordings'),
),
body: FutureBuilder(
future: ref.watch(userProvider.notifier).getRecordings(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(snapshot.data![index].url),
subtitle: Text(snapshot.data![index].date),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Recording(
url: snapshot.data![index].url,
),
),
);
},
);
},
);
} else {
return const Center(child: CircularProgressIndicator());
}
}),
);
}
}
それから、video_playerパッケージを使って、ビデオを新しい画面で表示します。
class Recording extends StatefulWidget {
final String url;
const Recording({super.key, required this.url});
@override
State<Recording> createState() => _RecordingState();
}
class _RecordingState extends State<Recording> {
late VideoPlayerController _controller;
late Future<void> _initializeVideoPlayerFuture;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.network(
"https://agora-server.s3.us-east-2.amazonaws.com/${widget.url}",
);
_initializeVideoPlayerFuture = _controller.initialize();
_controller.setLooping(true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
if (_controller.value.isPlaying) {
_controller.pause();
} else {
_controller.play();
}
});
},
child: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
),
),
);
}
}
文字起こし機能
リアルタイムの文字起こし機能の利用方法はCloud Recording機能に似ています。この機能でもバックエンドサービスと組み合わせてAgora RESTful APIを使います。
セキュリティの観点からAgora RESTfull API利用のためのCustomer IDとCustomer Secretをアプリに保存しないので、バックエンドサービスを利用します。このサーバーはPythonとFlaskで構築されています。コードはこちらをご参照ください。この記事では、Agora APIに関わる部分だけを紹介するので、Flaskでゼロからサーバーを構築することについてもっと知りたい場合は、この動画をおすすめします。
今回はstart-transcribingとstop-transcribingの関数をバックエンドサーバーに実装します。
文字起こし機能を起動
バックエンドがどのチャンネルで文字起こしタスクを実行するかを分かるためにstart-transcribingのエンドポイントに対象チャンネルの情報を設定する必要があります。文字起こしのタスクが開始されると、音声をテキスト化したデータはどこに保存されるかを把握できるようにユーザー(呼び出し元)にエンドポイント、タスクID(task ID)、トークン(builder token)を送ります。
また、文字起こしタスクを実行するための最初のステップとしては、文字起こし用のリソースを作ることです。これに必要な手順は前述したようにまずAgora Console上でAgora RESTfull API利用のためのCustomer IDとCustomer Secretを生成して、この二つの情報で文字起こし用のリソースを作るための認証情報を生成します。
def rtt_generate_resource(channel):
payload = {
"instanceId": channel,
}
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
headers['Access-Control-Allow-Origin'] = '*'
url = f"https://api.agora.io/v1/projects/{APP_ID}/rtsc/speech-to-text/builderTokens"
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
tokenName = data["tokenName"]
return tokenName
この認証情報でacquireを実行して文字起こし用のリソースを作成します。このメソッドにてtokenName(トークン名)が発行されます。このtokenNameで文字起こし機能を起動します。
def rtt_generate_resource(channel):
payload = {
"instanceId": channel,
}
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
headers['Access-Control-Allow-Origin'] = '*'
url = f"https://api.agora.io/v1/projects/{APP_ID}/rtsc/speech-to-text/builderTokens"
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
tokenName = data["tokenName"]
return tokenName
これで文字起こし機能を起動する準備ができました。上記の他に必要な設定について詳しくはこちらをご覧ください。
文字起こしについての設定は以下を示します。
- 今回の利用言語は英語とスペイン語です。
- 文字に起こしたテキストデータをrttフォルダに保存します。
def start_transcription(channel):
tokenName = rtt_generate_resource(channel)
url = f"https://api.agora.io/v1/projects/{APP_ID}/rtsc/speech-to-text/tasks?builderToken={tokenName}"
payload = {
"audio": {
"subscribeSource": "AGORARTC",
"agoraRtcConfig": {
"channelName": channel,
"uid": "101",
"token": "{{channelToken}}"
"channelType": "LIVE_TYPE",
"subscribeConfig": {
"subscribeMode": "CHANNEL_MODE"
},
"maxIdleTime": 60
}
},
"config": {
"features": [
"RECOGNIZE"
],
"recognizeConfig": {
"language": "en-US,es-ES",
"model": "Model",
"output": {
"destinations": [
"AgoraRTCDataStream",
"Storage"
],
"agoraRTCDataStream": {
"channelName": channel,
"uid": "101",
"token": "{{channelToken}}"
},
"cloudStorage": [
{
"format": "HLS",
"storageConfig": {
"accessKey": ACCESS_KEY,
"secretKey": SECRET_KEY,
"bucket": BUCKET_NAME,
"vendor": 1,
"region": 1,
"fileNamePrefix": [
"rtt"
]
}
}
]
}
}
}
}
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
res = requests.post(url, headers=headers, data=json.dumps(payload))
data = res.json()
taskID = data["taskId"]
return taskID, tokenName
以下のコール内容をAgoraのStatefulWidgetのinitStateに追加することで、プロセスを起動します。
final response = await http.post(
Uri.parse(
'https://agora-server-hr4b.onrender.com/start-transcribing/main'),
);
taskId = jsonDecode(response.body)['taskId'];
builderToken = jsonDecode(response.body)['builderToken'];
Agoraのイベントに基づいてアクションをカスタマイズ
エンドポイントをコールするだけではアプリに何も表示されないので、こちらでプロジェクトのprotobufをセットアップする必要があります。
以下はAgoraリアルタイムの文字起こしのテンプレートになります。
syntax = "proto3";
package agora.audio2text;
option java_package = "io.agora.rtc.audio2text";
option java_outer_classname = "Audio2TextProtobuffer";
message Text {
int32 vendor = 1;
int32 version = 2;
int32 seqnum = 3;
int32 uid = 4;
int32 flag = 5;
int64 time = 6;
int32 lang = 7;
int32 starttime = 8;
int32 offtime = 9;
repeated Word words = 10;
}
message Word {
string text = 1;
int32 start_ms = 2;
int32 duration_ms = 3;
bool is_final = 4;
double confidence = 5;
}
Protocol Bufferのセットアップが完了すると、onStreamMessageのコールバックからテキスト化したデータを取得し、protobufを通してこのテキストをリストに追加することができます。
agoraEventHandlers: AgoraRtcEventHandlers(
onStreamMessage:
(connection, remoteUid, streamId, data, length, sentTs) {
protoText.Text sttText = protoText.Text.fromBuffer(data);
if (sttText.words.isNotEmpty) {
sttText.words.last.isFinal
? updateConversation(sttText.words.last.text)
: null;
}
},
onStreamMessageError:
(connection, remoteUid, streamId, code, missed, cached) {
print("error $code");
},
)
以下のviewをアプリの画面に追加すれば、文字がリアルタイムで表示されます。
Padding(
padding: const EdgeInsets.only(bottom: 100.0, top: 200),
child: ListView.builder(
controller: _controller,
itemBuilder: (context, index) {
return ListTile(
title: Text(
conversation[index],
style: const TextStyle(
color: Colors.white,
),
),
);
},
itemCount: conversation.length,
),
),
文字起こし機能を終了
通話終了後に最後のステップとしては、文字起こしタスクを終了することです。こちらで必要なのは、/stop-transcribing/<--Channel Name-->/<--Task ID-->/<--Builder Token-->のフォーマットに従うエンドポイントです。
def stop_transcription(task_id, builder_token):
url = f"https://api.agora.io/v1/projects/{APP_ID}/rtsc/speech-to-text/tasks/{task_id}?builderToken={builder_token}"
headers = {}
headers['Authorization'] = 'basic ' + credential
headers['Content-Type'] = 'application/json'
payload = {}
res = requests.delete(url, headers=headers, data=payload)
data = res.json()
return data
通話終了ボタンが押されるタイミングでFlutterアプリからこのエンドポイントをコールします。これにより、当該セッションのリアルタイムの文字起こし機能は終了になります。
http.get(Uri.parse(
'https://agora-server-hr4b.onrender.com/stop-transcribing/$taskId/$builderToken'));
他のコア機能
Apple Payを統合
アプリでApple Payを利用するにあたってApple Payの利用業者の申請かつマーチャントID(merchant ID)を自前のアプリと紐付ける必要があります。これについての詳細は、こちらのApple Payガイドをご参照ください。
必要なセットアップが完了すると、Apple PayボタンのonPaymentResult引数でuser providerが定義しているjoinSession関数を実行します。この関数はレッスンを購入したユーザー(生徒)の受講リストにセッションを追加すると同時に、このユーザーのIDを当該レッスンの受講生リストに追加します。
Future<void> joinSession(String sessionId, bool isLecture) async {
await _firestore.collection("users").doc(state.id).update({
'upcomingSessions': FieldValue.arrayUnion([sessionId])
});
if (!isLecture) {
await _firestore.collection("sessions").doc(sessionId).update({
'students': FieldValue.arrayUnion([state.id])
});
}
state = state.copyWith(
user: state.user.copyWith(
upcomingSessions: [...state.user.upcomingSessions, sessionId]));
}
無料でAgoraをご体験いただけます。
こちらからサインアップし、リアルタイムの音声とビデオ通話を実装してみましょう!
利用分数が多くなければ料金は発生しません。
Agoraの導入について、お気軽にお問い合わせ下さい。
執筆者ブイキューブ