ygoto3.com

Front-end engineer at CyberAgent

Alexa アプリ(スキル)開発効率化メモ:ローカル開発/継続的インテグレーション/多言語対応

Amazon Echo

担当している AbemaTVAmazon Alexa に対応しました。今回 Alexa スキル(Alexa に機能を追加するためのアプリをスキルと呼びます)の開発にあたって、課題感があったチームによる平行開発、継続的インテグレーションおよびデプロイ、多言語対応についてのメモを残したいと思います。

Web 技術を使って開発できるけど動作確認が大変

最近は Web で使われてきた技術がさまざまなデバイス用のプラットフォームでも利用できるようになり、Web エンジニアがこういった PC やスマートフォン以外のデバイス向けのアプリを開発することも多くなってきました。たとえば Amazon Echo などの Alexa 搭載端末や Google Chromecast 用アプリも Web 技術を使って開発できるので Web エンジニアが参入する障壁はかなり低いのですが、既存の Web アプリケーションと大きく違うのが、実機で動作確認するのが一苦労な点です。

Web アプリケーションであれば、実装している PC 上でビルドして同じ PC で Web サーバを立てるだけでほかの PC やスマートフォンからアクセスして動作確認できます。しかし、Amazon Echo や Google Chromecast のようなデバイスは、各々のアプリ開発者ポータルに登録されたアプリにしかアクセスできないようになっています。もちろん PC に直接デバイス接続するような機能もプラグもありません。両者とも物理的なインターフェースがシンプルすぎます。

実機で自作アプリの動作確認をするには毎回オンラインでアクセスできるどこかにデプロイする必要があります。アプリを実装する場所(PC)から動作テストを行える場所(オンラインのどこか)までがやたら遠いので、どうしても開発効率が悪くなりますし、複数人で平行して開発しようとすると人数分のテスト環境をどこかにホストする必要があり、そういった環境を別途管理するなど煩雑さも増します。

Alexa スキルをチームで開発するときの課題

そんな中、現在サービス開発を担当している AbemaTV で、いわゆるスマート・デバイス向けのアプリ開発を専門で担当するチームを立ち上げました。Alexa スキルをチームで開発するとなっても、平行開発しづらいこの環境ではマンパワーのメリットが活かせません。平行開発を可能にするために以下の要素を満たす必要があります。

  • 開発に使っているローカル PC 上である程度の動作確認が可能なこと
  • 継続的インテグレーションが可能なこと

この 2 点を実現する手段について紹介するのですが、Alexa スキルの開発が初めての方のために一般的な開発手順について認識がない方のために、まず Alexa スキルの概要と開発手順をざっくり説明します。

Alexa スキルの開発手順

Alexa スキルと呼ばれるものは、家電製品などを制御するためのスマートホームスキル、ニュースなどを読み上げるためのフラッシュブリーフィングスキルなど用途が特化しているものもありますが、今回は用途が汎用的なサービスを作ることができるカスタムスキルについてお話します。

カスタムスキルの開発手順は大まかに以下のようになります。

  1. Voice User Interface (VUI) 作成
  2. サービス・ロジック実装
  3. VUI からサービス・ロジックへの連携
  4. 実機やシミュレータによるテスト
  5. 申請
  6. 審査
  7. 公開

具体的な実装手順自体は Amazon Alexa が公式に公開しているチュートリアル Build An Alexa Fact Skill を一通りやると大体分かります。あと、昨日クラスメソッドさんが投稿している 【祝Alexa日本上陸】とりあえず日本語でスキルを作ってみる もスキルの作り方がとても分かりやすいのでオススメです。

Alexa スキルを開発するには、普段聞き慣れない Alexa 特有の概念をいくつか理解する必要があります。個人的には最初分かりづらかったので、補足がてら簡単に説明します。

Voice User Interface - VUI

Voice User Interface(以後 VUI)というのは、その名前の通り、声で操作するユーザー・インターフェースです。PC デスクトップ・アプリやスマートフォン・アプリでいうところのボタンとかテキスト入力ボックスなどにあたります。PC やスマートフォン上のアプリケーションはマウスやタッチパッドで操作するので、ボタンがクリックされたりテキストが入力されたときにアプリは特定の処理を実行します。しかし、Alexa の場合は操作手段が声です。どのように話しかけたときにどんな処理につなげるかを橋渡しする存在が VUI です。

VUI の少し深い話はこちらの記事など参考になりますが、とても抽象性が高い概念なので、ここでは以下の Alexa の VUI を構成する具体的な要素 3 つがどのようにユーザーの声とサービスをつなげているかを見ていきます。

  • Invocation Name(呼び出し名)
  • Intent(意図)
  • Sample Utterance(発話サンプル)

Invocation Name

Invocation Name はいわゆるスキルの呼び出し名です。つまり、Alexa から特定のスキルを使いたいときに指定する名前です。スマホだとホームスクリーンでアプリを起動するときにアイコンをタップすると思いますが、Invocation Name に相当します。たとえば、AbemaTV スキルを呼び出したい場合であれば、AbemaTV が Invocation Name なので、「Alexa、AbemaTV を開いて」と話しかけると AbemaTV スキルが起動します。

Sample Utterance

Sample Utterance は、発話のサンプルです。ユーザーが実際に発話した文言をどんな意図(Intent)として受けるかを判断するための要素です。たとえばユーザーがランキングを知りたいときに、質問の仕方は何パターンもあります。ある人は「ランキングを教えてー」と言うかもしれませんし、別の人はランキングという言葉を使わず「いま人気の番組は何?」と尋くかもしれません。ただ、厳密な発話の仕方が異なってもユーザーが聞きたいことは結果同じだったりします。こういった異なる発話パターンをスキルがどんな意図として解釈するのかをマッピングするのが Sample Utterance の役割です。

1
2
3
RankingIntent ランキングを教えて
RankingIntent ランキングが知りたい
RankingIntent いま人気の番組は何

上記のような Sample Utterance を書いた場合、「Alexa、AbemaTV のランキングを教えて」 と言っても、 「Alexa、AbemaTV のランキングが知りたい」 と話しかけても 「Alexa、AbemaTV でいま人気の番組は何?」 と訊いても全てランキングを知りたいという意図と解釈して処理するようになります。意図には名前が付けることができ、ここでは RankingIntent という名前にしています。この名前を指定することは、次に説明する Intent に処理を接続するために重要です。

Intent

Intent は Sample Utterance によってマッピングされたユーザーの意図に対して実際のどんな処理を行う部分です。例の RankingIntent の場合はユーザーがランキングを知りたいという意図に対する処理なので、実際に現在のランキング・データを取得して、それをユーザーに回答するための文章を作成します。

Amazon Echo などの Alexa 対応デバイスはユーザーの発話音声をクラウド上の Alexa サービスに送り音声からユーザーの意図を解釈した後は、具体的なサービス・ロジックの処理依頼を AWS Lambda のような別サービスにリクエストします。Alexa が Amazon のプロダクトなので、チュートリアルにあるように AWS Lambda 上に処理を実装すると連携も簡単ですし、Amazon が用意している SDK の恩恵にあずかることができます。このフローは Alexa 公式ブログの記事 Alexaスキル開発トレーニングシリーズ 第1回 初めてのスキル開発 の下記スキル実行仕組みの図が分かりやすいです。

Alexa スキル実行仕組みの図

Intent に対するサービス・ロジックの実装

Alexa サービスから Intent が指定されて AWS Lambda にリクエストが飛んできます。その Intent に対するサービス・ロジックを Lambda Function として実装します。Amazon が提供する Alexa Skills Kit SDK for Node.js では、Alexa をトリガーに呼び出された AWS Lambda のイベント情報を SDK を通じて Intent ごとのハンドラに振り分けてくれます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const Alexa = require('alexa-sdk');
// Intent ごとの処理を書いていく
const intentHandlers = {
'RankingIntent': function () {
this.response.speak(`本日のランキングは${getRanking()}です。`);
this.emit(':responseReady');
},
'AMAZON.HelpIntent': function () { ... },
'AMAZON.CancelIntent': function () { ... },
'AMAZON.StopIntent': function () { ... },
'LaunchRequest': function () { ... },
};
// Lambda のイベントとコンテキスト情報を SDK に渡して、Intent のハンドラにつなげる
exports.handler = function(event, context, callback) {
const alexa = Alexa.handler(event, context);
alexa.appId = APP_ID;
alexa.registerHandlers(intentHandlers);
alexa.execute();
};

この Lambda Function のエンドポイントを自作のスキルから接続するように設定することで Alexa にユーザーと対話させることができるようになります。

余談ですが、2017 年 11 月現在 AWS Lambda の Node.js サポート・バージョンが v6.10 なので、本記事での JavaScript コードは全て v6.10 用になっています。

1
2
3
$ n
ο node/6.10.0
node/9.0.0

Alexa スキルの開発手順を効率化したい

ここまでが Build An Alexa Fact Skill チュートリアルに載っている内容です。この内容をカスタマイズすれば、Alexa スキルを開発することはできます。しかし、前述したように、このスキルを動作確認するには毎回 AWS Lambda 上にコードをデプロイする必要があります。これでは開発効率も悪いです。しかも Alexa スキルは Lambda Function に紐づけて管理するので、チームで開発するとなると人数分のスキル設定と Lambda Function も別途用意しておく必要があります。しばらく開発してると、少々手間がかかりすぎるのでちょっと辛くなり、次のようなことができる環境が欲しいなと思い始めます。

  • Amazon Skills Kit やAWS Lambda にデプロイすることなくローカルで気軽にテストしたい
  • スキル設定と Lambda Function をまとめて継続的にインテグレーションしたい

結論を言うと、前者は alexa-app というサードパーティーの Alexa スキル用フレームワークを利用することで解決して、後者は Amazon が提供する ASK-CLI という Alexa スキル管理のためのコマンドラインツールを CI ツールで走らせることで解決しました。

ローカルで Alexa スキルを開発する

ローカルで Alexa スキルを動作確認したいと思っている人は多いだろうなと思い、ネット上で記事を漁っていると、やはり同じことを考えている人がちょこちょこいるようです。書かれたのが 2016 年 3 月と少し古いですが、ローカルで Alexa スキルを開発するための詳しい手順が書かれた Big Nerd Ranch さんによる Developing Alexa Skills Locally with Node.js: Implementing an Intent with Alexa-app and Alexa-app-server という記事を見つけました。

alexa-app

alexa-app は、Alexa スキルを開発するためのサードパーティ製のフレームワークです。基本機能としては Alexa からの JSON リクエストを簡単に扱うための API や Alexa へ返すレスポンスを簡単に生成するための API を提供してくれます。実装が少し楽になるにはなるのですが、ただそれだけのメリットだと、サードパーティ製ということもあり将来的なメンテナンスとか考慮すると使うのを躊躇するところです。しかし、これを使いたく一番大きな理由は、今回の課題である「ローカルでのスキル・テスト」と「スキル設定と Lambda Function の継続的インテグレーション」を実現するために必要な次の 2 つの機能を提供してくれるからです。

  • 実装したスキルを Express にも連結できる
  • Intent と Sample Utterance もフレームワークで管理できる

スキルを Express アプリとしてテスト可能

実機である Alexa 端末から Intent 処理のリクエストを AWS Lambda で受ける必要があるので、alexa-app で実装したスキルを AWS Lambda のハンドラとして連結することは当然可能ですが、このフレームワークは同じ実装コードを任意の Express サーバに接続することも可能です。これで Alexa スキルを Express アプリのようにテストできます。

たとえば簡単な Alexa スキルを alexa-app で書いてみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const alexa = require('alexa-app');
const app = new alexa.app('sample-alexa-skill');
function LaunchRequest(req, res) {
const prompt = '人気の番組は何?、と訊いてください';
res
.say(prompt)
.reprompt(prompt)
.shouldEndSession(false);
};
app.launch(LaunchRequest);
module.exports = app;

これは「Alexa、AbemaTV を開いて」と話しかけたときに Alexa に「人気の番組は何?、と訊いてください」と答えさせる処理を alexa-app で書いたものです。このテストを Jasmine で書くと:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const express = require('express');
const request = require('supertest');
const app = require('./app');
describe('Sample Alexa Skill', () => {
var server;
beforeEach(() => {
// alexa-app で書いたスキルを任意の Express アプリと接続する
const expressApp = express();
app.express({
expressApp,
debug: true,
checkCert: false,
});
server = expressApp.listen(3000);
});
afterEach(() => {
server.close();
});
it('responds to a launch intent', () => {
return request(server)
.post('/sample-alexa-skill')
.send({
request: {
type: 'LaunchRequest',
}
})
.expect(200).then(res => {
const actual = res.body.response.outputSpeech.ssml;
const expected = '<speak>人気の番組は何?、と訊いてください</speak>';
return expect(actual).toBe(expected);;
});
});
});

const app = require('./app'); で読み込んでいるのが alexa-app のインスタンスです。これを Express に接続することで SuperTest などを使って通常の Express HTTP サーバをテストするのと同じ感覚でテストを書くことができます。

一点、注意なのですが、alexa-app インスタンスはそのままだと AWS Lambda に接続できないので、別途 index.js などのエントリーポイントを作成して以下のようにハンドラに渡すようにしておきます。

1
module.exports.handler = require('./app').lambda();

alexa-app-server でデバッグ

alexa-app の Express 用のインターフェースを利用すればローカルでのデバッグ作業もかなり楽になります。

任意のリクエストに対するデバッグを行うときに、毎回リクエストを生成するコードを書くのは手間です。そこで alexa-app で書いたスキル用の Web サーバ alexa-app-server を使うと Web ブラウザから GUI で簡単にリクエストを生成できます。

alexa-app-server のスクリーンショット

alexa-app-server の設定は簡単です。まずプロジェクトに alexa-app-server を npmyarn でインストールします。

1
$ yarn add alexa-app-server

プロジェクトのルート・ディレクトリ直下に apps というディレクトリを作成して、そこに作成した alexa-app アプリのプロジェクトを移動します。(シンボリックリンクでも構いません。)

1
$ mkdir apps && mv ./somewhere/sample-alexa-skill apps

alexa-app-server は alexa-app インスタンスのモジュールを探すときに package.jsonmain プロパティの値をパスとして確認します。もしプロジェクトに package.json がない場合や main プロパティの値が alexa-app インスタンスのファイル・パスを指していない場合は変更します。

1
2
3
4
5
{
...
"main": "app.js",
...
}

サーバの設定を記述します。alexa-app-server をインストールした方のプロジェクトのルート・ディレクトリに index.js という名前でファイルを作成し次のように記述します。

1
2
3
4
5
6
7
8
9
const AlexaAppServer = require('alexa-app-server');
AlexaAppServer.start({
server_root: __dirname, // サーバ・ルートへのパス
public_html: 'public_html', // 静的コンテンツ
app_dir: 'apps', // alexa-app を置くディレクトリ。複数の alexa-app アプリを置くことができます
app_root: '/alexa/', // サービスのルート。これ以下に各 alexa-app のエンドポイントが作られる
port: 8080 // 使用するポート
});

保存したら起動してみます。

1
$ node index.js

http://localhost:8080/alexa/sample-alexa-skill にアクセスすると自分が作ったスキル向けの JSON リクエストを生成できるインターフェースが表示されます。ここでスキルに実装済のインテントをプルダウンで設定したり任意の値を入力できるので、ローカルで効率的にスキルをデバッグすることが可能です。

alexa-app-server でのデバッグ

多言語対応

スキルを多言語対応する場合、Alexa からのリクエストにロケール情報が入っているので、それを使って地域/言語別にレスポンスを変更できます。

alexa-app-server のロケール別のリクエスト切り替え

alexa-app-server はロケール別のリクエスト切り替えがとても簡単なため多言語対応に関しても重宝します。通常、ロケールを頻繁に変更しながらのテストは大変です。Amazon Echo などの端末は Alexa の管理コンソール での登録時にしかロケールを変更できないように見えますし、Amazon 開発者コンソールのシミュレータも言語ごとに分けられているため、ロケールを頻繁に変更するテストには向いていません。

alexa-app-server のインターフェース上、まだ ja-JP ロケールがオプションから選択できません。フォークして ja-JP をオプションに追加したものを使っています。こちらプル・リクエスト中

継続的インテグレーション

alexa-app を使うことで Express に連結してローカルで擬似的にテスト・デバッグできる範囲が広がり、複数人での平行チーム開発も可能になりました。しかし、チームで平行開発できるようになると今度はインテグレーションが問題になってきます。特に Alexa スキルの場合、Lambda Function とは別にスキル設定を Amazon 開発者コンソールで管理しているので、Lambda Function 用の最新コードにスキル設定が一致しないことが発生します。そういった不一致を発生させないために:

  • スキル情報と Lambda Function の最新コードを常に同期する
  • 同期タイミングは開発コードがメイン・レポジトリへ統合するタイミング

などが実現できれば嬉しいです。前者については、スキル情報とスキルに紐づいた Lambda をまとめて操作できるコマンドラインツールの Alexa Skill Kit Command-line Interface (ASK CLI) を使うことで同期を取ることができます。後者については、ソースコードのバージョン管理に Git/GitHub を使っているのであれば、GitHub との連携が簡単な CI ツールで ASK CLI を走らせれば実現できます。本記事では、CircleCI を使います。

ASK CLI を使うための認証

ASK CLI を使うために Amazon Developer アカウント と AWS ユーザーの認証が必要です。今回は AWS Lambda 用のコードも含めてスキル管理したいので、ASK CLI から AWS を使える状態にする必要があります。

AWS CLI のユーザー認証

ASK CLI の認証時に AWS の認証情報を紐づけたいので、先に AWS ユーザーを認証します。(AWS CLI を既に使ったことがある方で認証済のプロファイルがある場合は、ここは読み飛ばしていただくのが良いでしょう。)

まず AWS CLI をインストールします。Python パッケージで提供されているので、 pip などでインストールします。

1
$ pip install awscli

インストール完了後、 which aws などでパスが表示されることを確認できたら、次に AWS のユーザー認証を行います。AWS のアカウントがまだない場合は アマゾン ウェブ サービス(AWS) で作成します。AWS のアカウントを持っている場合は、ログインして IAM Management Console サービスの Users で AWS CLI 用のユーザーを作成します。作成時に表示される AWS Access Key IDAWS Secret Access Key をメモしておきます。

Alexa スキルに紐づける Lambda Function を作成したり、IAM の操作も許可する必要があるので、作成したユーザーに下記のポリシーを追加します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt000001",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:AttachRolePolicy",
"iam:PassRole",
"lambda:CreateFunction",
"lambda:AddPermission",
"lambda:GetFunction",
"lambda:UpdateFunctionCode"
],
"Resource": [
"*"
]
}
]
}

作成した AWS CLI 用のユーザーで AWS CLI を認証します。認証は aws configure コマンドで行います。

1
2
3
4
5
$ aws configure
AWS Access Key ID [None]: メモした AWS Access Key ID
AWS Secret Access Key [None]: メモした AWS Secret Access Key
Default region name [None]: us-east-1
Default output format [None]:

Default region name ですが、Alexa Skills Kit のドキュメントに以下のように書いてあり、限られたリージョンでしか AWS Lambda の Alexa Skills Kit のサポートをしていないので注意が必要です。

Lambda functions for Alexa skills can be hosted in either the US East (N. Virginia) or EU (Ireland) region. These are the only regions the Alexa Skills Kit supports.

これで AWS CLI が認証できました。認証情報が ~/.aws/config~/.aws/credentials に保存されていれば OK です。

1
2
3
$ cat ~/.aws/config
[default]
region = us-east-1

1
2
3
4
$ cat ~/.aws/credentials
[default]
aws_access_key_id = XXXX
aws_secret_access_key = XXXXXXXX

ASK CLI のアカウント認証

次に ASK CLI を Amazon Developer アカウントに認証します。 ask init コマンドを使って AWS CLI でユーザー認証したプロファイルと紐づけながら Amazon Developer アカウントへの認証手順が進みます。

1
2
3
4
5
6
7
8
9
$ ask init
-------------------- Initialize CLI --------------------
Setting up ask profile: [default]
? Please choose one from the following AWS profiles for skill's Lambda function deployment.
(Use arrow keys)
❯ default
──────────────
skip AWS credential for ask-cli
──────────────

初めて実行する場合は下記のようなダイアログが表示されますが、既にデフォルトのプロファイルがある場合は、新規プロファイルを作成するのか、既存プロファイルを上書くのかを尋かれるダイアログが表示されます。

ここで紐づけたい AWS のプロファイルを選択します。例では、先程 aws configure でユーザー認証したときプロファイル指定をしていないので、 default という名前で保存されているので、 default を選択します。

Web ブラウザが起動し、「Login with Amazon」のページで表示されるのでログインします。

Login with Amazon のスクリーンショット

次に権限の確認をされるので、問題なければ「Okay」ボタンをクリックします。

Amazon 権限確認画面のスクリーンショット

無事ログインが成功すると、Sign in was successful. Close this browser and return to the command line interface. というメッセージでブラウザを閉じろと言われるので閉じます。

ASK CLI の認証情報に関しては ~/.ask/cli_config に保存されています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cat ~/.ask/cli_config
{
"profiles": {
"default": {
"aws_profile": "default",
"token": {
"access_token": “XXXXXXX”,
"refresh_token": “XXXXXXX”,
"token_type": "bearer",
"expires_in": 3600,
"expires_at": "20XX-XX-XXTXX:XX:XX.XXXZ"
},
"vendor_id": "XXXXXXXXXXXXXX"
}
}
}

ASK プロジェクトを作成

ユーザー認証が通ったので、ASK CLI でスキル全体を管理できるようにプロジェクトを新規作成します。

1
$ ask new --skill-name sample-alexa-skill --lambda-name sample-alexa-skill

これで新規の Alexa スキル・プロジェクトの雛形が作成されます。tree コマンドを実行すると次のようなディレクトリ・ツリーが表示されるはずです。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tree
.
└── sample-alexa-skill
├── lambda
│   └── custom
│   ├── index.js
│   ├── node_modules
│   │   └── ...
│   ├── package-lock.json
│   └── package.json
├── models
│   └── en-US.json
└── skill.json

この中で重要な各要素の役割はざっくりと次の通りです。

要素 役割
lambda Lambda 用のコードを格納するディレクトリ
models Intent Schema や Sample Utterance などを格納するディレクトリ
skill.json スキルの申請に必要な情報を記述するファイル

雛形ができたので、スキルに必要な情報を設定していきます。

まず Alexa は Apple の App Store などと同様にスキルを公開するのに申請が必要なので、skill.json にこのスキルの名前やこのスキルを使うためのフレーズ例など、申請に必要な情報を記述します。

次に lambda ディレクトリには、今回 alexa-app で作った AWS Lambda 用モジュールを格納します。生成された lambda ディレクトリ以下の雛形は必要ないので custom ディレクトリごと削除してしまい、代わりに AWS Lambda 用モジュールのディレクトリを custom という名前でここに移動します。

1
2
$ rm -rf lambda/custom
$ mv somewhere/sample-alexa-skill lambda/custom

最後に models ディレクトリにスキルの Intent のデータ構造を示した Intent Schema と Sample Utterance の情報を格納する必要があるのですが、これらは alexa-app フレームワーク上の実装コード内に記述されています。なので、フレームワークの API を使って JSON ファイルとして出力するスクリプトを書きます。

1
2
3
4
5
6
7
8
9
const app = require('../index');
// alexa-app で実装したアプリ・オブジェクトは schemas.askcli で
// ASK プロジェクト用の Interaction Model JSON を出力できる
// 引数に Invocation Name 呼び出し名を渡す
const interactionModel = app.schemas.askcli('Sample Alexa Skill');
// JSON を標準出力に流します
process.stdout.write(interactionModel);

このスクリプトを実行した出力をロケール ID をファイル名にした JSON にパイプします。日本語であれば models/ja-JP.json にパイプします。

1
$ node ./scripts/gen-interaction-model.js > ./models/ja-JP.json

ja-JP.json の中身はこんな感じです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"interactionModel": {
"languageModel": {
"intents": [
{
"name": "SayNumber",
"samples": [
"数字の {number} を言って"
],
"slots": [
{
"name": "number",
"type": "AMAZON.NUMBER",
"samples": []
}
]
}
],
"types": [],
"invocationName": "Sample Alexa Skill"
}
}
}

多言語対応する場合は、必要な分、別のロケール ID の JSON ファイルにパイプします。ロケール ID をファイル名にした JSON ファイルを複数 models ディレクトリに入れておくことにより、ASK CLI が言語別のスキル情報として登録してくれます。ここでは日本語と英語に対応するために ja-JS.json とは別に en-US.json を書き出します。

1
$ APP_LOCALE=en-US node ./scripts/gen-interaction-model.js > ./models/en-US.json

ここでは、環境変数 APP_LOCALE に応じて Sample Utterance が切り替わるように alexa-app の Intent を実装しました。 en-US.json の Sample Utterance 部分などが差し替わって出力されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"interactionModel": {
"languageModel": {
"intents": [
{
"name": "SayNumber",
"samples": [
"say the number {number}"
],
"slots": [
{
"name": "number",
"type": "AMAZON.NUMBER",
"samples": []
}
]
}
],
"types": [],
"invocationName": "Sample Alexa Skill"
}
}
}

言語に関する設定は models ディレクトリのほかに skill.json ファイルにも記述する必要があるので、必要に応じてロケール情報を追加しましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"skillManifest": {
"publishingInformation": {
"locales": {
"en-US": {
"summary": "Sample Alexa Skill's Short Description",
"examplePhrases": [
"Alexa open Sample Alexa Skill",
"Alexa tell Sample Alexa Skill say the number 1",
"Alexa tell Sample Alexa Skill say the number 3"
],
"name": "sample-alexa-skill",
"description": "Sample Alexa Skill's Full Description"
},
"ja-JP": {
"summary": "Sample Alexa Skill の説明",
"examplePhrases": [
"アレクサ、Sample Alexa Skill を開いて",
"アレクサ、Sample Alexa Skill で数字の1を言って",
"アレクサ、Sample Alexa Skill で数字の3を言って"
],
"name": "sample-alexa-skill",
"description": "Sample Alexa Skill の詳細な説明"
}
},
"isAvailableWorldwide": true,
"testingInstructions": "Sample Alexa Skill's Testing Instructions.",
"category": "EDUCATION_AND_REFERENCE",
"distributionCountries": []
},
"apis": {
"custom": {
"endpoint": {
"sourceDir": "lambda/custom"
}
}
},
"manifestVersion": "1.0"
}
}

これで alexa-app で実装した多言語対応スキルを ASK プロジェクトとして管理できるようになりました。

Alexa スキルをデプロイする

作成した ASK プロジェクトを Amazon Echo などの実機で試すためには、Alexa スキルとしてデプロイする必要があります。ASK CLI の deploy コマンドを使うだけです。

1
$ ask deploy

Alexa スキルがデプロイされたことを確認するため、Amazon 開発者コンソール に行き、Alexa Skills Kit のスキル・リストに sample-alexa-skill が登録されているか確認します。

Alexa Skills Kit のスキル・リスト

GitHub 連携で CI

ASK CLI で Alexa スキルをデプロイできるところまで来たので、あとはこの手順を CI ツールに設定すれば継続的にテストしたりデプロイしたりすることができます。もちろん CI ツールは何でも構いませんが、ここでは GitHub に簡単に連携ができる CircleCI を使って、GitHub Flow ベースで単純で DevOps な感じの運用ができればいいなというイメージ。

デプロイ時に必要な処理の依存関係を Makefile にまとめる

CircleCI に処理を書いていく前に、スクリプトの実行手順に若干の依存関係ができてしまったので、明示的に手順を示す意味で Makefile にまとめます。

1
2
3
4
5
6
7
8
.PHONY: interaction_model
interaction_model:
node ./lambda/custom/scripts/gen-interaction-model.sh > models/ja-JS.json
APP_LOCALE=en-US node ./lambda/custom/scripts/gen-interaction-model.sh > models/en-US.json
.PHONY: deploy
deploy: interaction_model
ask deploy

先程も説明した通り、多言語対応する場合は、環境変数別に gen-interaction-model.sh を走らせて言語別の Model を書き出す処理もまとめておきます。

CircleCI にインテグレーション/デプロイ処理を追加する

デプロイ時に必要な処理もまとまったので、CircleCI プロジェクト用にインテグレーション処理とデプロイ処理を書いていきます。ASK プロジェクト・ディレクトリ直下に .circleci/config.yml ファイルを作り、以下のような YAML でジョブを記述します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
defaults: &defaults
working_directory: ~/repo
docker:
# Use the same Node version as that of AWS Lambda's
- image: circleci/node:6.10
version: 2
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: v1-dependencies-{{ checksum "package.json" }}
- restore_cache:
key: v1-dependencies-{{ checksum "lambda/custom/package.json" }}
- run:
name: Install dependencies
command: yarn install
- run:
name: Install dependencies for Lambda function
working_directory: lambda/custom
command: yarn install
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "package.json" }}
- save_cache:
paths:
- lambda/custom/node_modules
key: v1-dependencies-{{ checksum "lambda/custom/package.json" }}
- persist_to_workspace:
root: .
paths:
- node_modules
- lambda/custom/node_modules
test:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Run tests
command: |
yarn test
- run:
name: Report code coverage
command: $(yarn bin)/codecov
deploy:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: .
- deploy:
name: Deploy Skill
command: |
sudo apt-get -y -qq install python3-pip gettext
sudo pip3 install awscli
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
aws configure set default.region us-east-1
sudo npm i ask-cli -g
mkdir -p ~/.ask
echo ${ASK_CLI_CONFIG} | base64 -d > ~/.ask/cli_config
make deploy
workflows:
version: 2
test_and_deploy:
jobs:
- build
- test:
requires:
- build
- deploy:
requires:
- build
- test
filters:
branches:
only: master

テストの実行やらコード・カバレッジの取得やらしていますが、前述したようにデプロイに関しては

  • スキル情報と Lambda Function の最新コードを常に同期する
  • 同期タイミングは開発コードがメイン・レポジトリへ統合するタイミング

のように自動化したかったので、CircleCI で GitHub の master ブランチへのマージのタイミングで下記 3 点の処理を実行するようにタスクを記述しています。

  • AWS CLI のインストールおよび設定
  • ASK CLI のインストールおよび設定
  • スキル設定と AWS Lambda コードのデプロイ

ローカル同様、CircleCI 上でも AWS と ASK の認証が必要です。それぞれ CircleCI 上でインストールして認証情報を設定します。

まず ASK CLI で AWS Lambda をデプロイするために必要なので AWS CLI をインストールします。AWS CLI を認証するために環境変数 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY を参照しています。なので、CircleCI 側の環境変数に両者を登録しておきます。

ASK CLI に関しては、先程の認証情報が ~/.ask/cli_config に記述してあるので Base64 にエンコードしてクリップボードにコピーします。Mac 系の OS なら pbcopy できるのでこんな感じです。

1
$ base64 ~/.ask/cli_config | pbcopy

クリップボードの中身を CircleCI 側の環境変数 ASK_CLI_CONFIG として設定します。これで GitHub にホストした master ブランチに変更をマージする度に Amazon 開発者コンソールの Alexa スキル情報と紐づいた AWS Lambda Function コードが最新状態に更新されるようになりました。

Alexa スキルを実際に公開するためには、スキルを申請して審査を通過する必要があります。ASK CLI は申請もコマンドラインで送信できるので、それも自動化したい人は ask api submit コマンドなど必要な手順を CI プロセスに追加してもいいかもしれません。

まとめ

Alexa スキルは Web 技術を使って簡単に開発を始めることができます。新しいデバイスなので勝手が掴めず、動作確認等々が大変な部分もあって最初はちょっととまどいましたが、探せば開発を助けるツールの恩恵を受けることができ、少しずつ開発しやすい環境を構築できるようになってるなと感じます。本記事がこれから Alexa スキルを開発をする人の効率化の参考になれば幸いです。

本記事の内容のサンプルコードは下記に上げてあります。

参照

フロントエンドエンジニアのための生放送と RTMP 通信基礎

生放送と RTMP 通信基礎

前回「フロントエンドエンジニアのための動画ストリーミング技術基礎」では HTTP ベースのストリーミング技術に関して勉強会を実施しました。視聴者に映像を届けるためのストリーミング技術に関してのお話でした。

本記事は、AbemaTV の生放送番組で撮影機材から送られた映像がエンコーダーを介してリアルタイムに放送する部分について勉強会を実施した際の資料です。

生放送における動画データの通信

AbemaTV では生放送で撮影した動画データのやりとりに RTMP というプロトコルを利用しています。

生放送の配信構成

RTMP とは

RTMP は Real-Time Message Protocol の略で、その名前の通りリアルタイムにコミュニケーションを行うためのプロトコルです。Web 業界では Photoshop などでお馴染の Adobe Systems 社が開発しています。Adobe Flash Player がメディア配信サーバーとの間で音声や動画などのデータをやりとりするためのストリーミングのためのプロトコルとして開発されました。

前回紹介した HLS や MPEG-DASH もストリーミングプロトコルでしたが、RTMP はこれらと異なり、HTTP ベースではありません。ですので、HLS や MPEG-DASH のように通常の Web サーバーでコンテンツを配信できるわけではなく、専用の RTMP サーバーが必要になります。

RTMP 専用サーバー

HTTP に対する優位性

HTTP における通信は必ずクライアントのリクエストから始まります。そのため、動画をストリーミングしようとする際、クライアントはサーバーに対して任意のインターバルで動画のセグメントをリクエストし続ける必要があります。HLS や MPEG-DASH におけるストリーミングはこの方式ですが、クライアントのタイミングで動画データをリクエストするため、本当の意味でのリアルタイム性はありません。動画データを生成しているサーバー側が送信したいタイミングでクライアントにデータをプッシュできる方が遅延が発生することがなく、効率的です。

それに対して、RTMP におけるデータ通信は持続的に接続した状態で双方向に行われます。そのため、サーバーがクライアントに送信したいタイミングでプッシュ送信することができ、遅延が発生しません。

また HTTP の場合、HTTP レスポンスヘッダーは冗長で一般的に数百バイトになります。返したいペイロードサイズに対してのオーバーヘッドを大きくしてしまっています。それに対し、RTMP パケットのヘッダーは固定長で 12 / 8 / 4 / 1 バイトのうちどれかになり、ペイロードサイズに対するオーバーヘッドは小さいです。

生放送の現場ではリアルタイム性を重視

生放送の配信構成

AbemaTV の生放送の現場では撮影機材で撮れた映像を Wirecast などのエンコーダーでエンコードし、それを RTMP 通信で Wowza などのメディアストリーミングサーバーに届けています。そして、メディアストリーミングサーバーに届けられた映像を確認しながら、生で撮影しているその映像に対して遅延を極力少ない状態で CM 入りや視聴者参加型コンテンツなどのトリガーとなるシグナルを通信できる環境を構築しています。

RTMP の種類

RTMP にはいくつかの派生種があります。

  • RTMPT - HTTP でカプセル化した RTMP
  • RTMPS - TLS/SSL で暗号化して HTTPS でカプセル化した RTMP
  • RTMPE - こちらも暗号化された RTMP ですが、設計に欠陥があり RTMPS の使用が推奨されている
  • pRTMP - Adobe Primetime DRM がかかった RTMP

RTMP は一般的に 1935 ポートを使用します。しかし、セキュリティの厳しい環境ではこの 1935 ポートが使えないこともしばしばあります。そのため、HTTP( 80 ポート)や HTTPS( 443 ポート)を装って通信するという手段を取ることが可能です。それが RTMPTRTMPS になります。

更にリアルタイム性を重視したデータ通信

RTMP は TCP を利用したプロトコルですが、別に RTMFP という UDP を利用したプロトコルもあります。UDP を利用するプロトコルは TCP を利用するプロトコルと比べて通信速度面において利点があります。TCP はパケット・ロストに対して再送する仕組みですが、UDP はパケット・ロストに対して再送することはありません。その分再送のオーバーヘッドなく通信することができます。

データ量の大きな双方向データ通信

RTMP は双方向のデータ通信が可能なプロトコルですが、両方向とも送信するデータ量が大きな通信サービスを構築する場合は UDP ベースの RTMFP が好まれます。たとえばテレビ会議やビデオチャットなどは双方が送信するデータが動画のため、データサイズが大きいにも関わらず、スムーズなコミュニケーションのためにリアルタイム性が求められます。

TCP で パケット・ロストによる再送で遅延の頻度が高まると、音声や映像が遅れた状態になる可能性が高くなります。特にテレビ会議やビデオチャットなどはデータの抜け落ちが発生したとしてもノイズ程度の劣化として許容できる場合がほとんどなため、UDP での通信が向いています。

RTMP をサポートするメディアストリーミングサーバ

RTMP でストリーミング配信するには、専用の RTMP サーバーが必要です。ここでは RTMP をサポートする代表的なメディアストリーミングサーバーを3つ紹介します。

Adobe Media Server

Adobe Media Server

Adobe Systems 社が開発しているメディアストリーミングサーバーです。Flash 技術の総本山である Adobe が開発しているだけあり、ここで紹介するメディアサーバーの中で一番知名度が高く、機能も豊富です。そしてその分ライセンス料も高いです。(RTMFP のサポート有無など機能数に応じて複数のエディションに別れています。)

参照:Adobe Media Serverファミリー

Wowza Media Server

Wowza Media Systems

Wowza Media Systems 社によって開発されているメディアストリーミングサーバーです。元 Adobe Systems の社員がスピンアウトして立ち上げたこともあり、Adobe Media Server との互換性が高く、ほぼ同等の機能を持っています。それにも関わらずライセンス価格は Adobe Media Server と比較するとかなり安価なこともあり、AbemaTV でも生放送のストリーミングサーバーには Wowza Media Server を使用しています。

Red5

Red5

Java で実装されたオープンソースの RTMP プロトコルをサポートするメディアストリーミングサーバーです。Adobe Media Server にかなり似せて作られていて、単体のサーバーとしては同等の機能を提供してくれますが、クラスタリング構成にあまり対応していないため、大規模な配信サービスを構築する場合には注意が必要です。

RTMP を使用するためのクライアントサイド

Web ブラウザは RTMP をネイティブでは対応していません。ブラウザ上で RTMP を使用するためには、プラグインとして Adobe Flash Player を使用する必要があります。

簡単な RTMP ストリーミング配信を実装してみる

まずは RTMP ストリーミングサーバーを構築します。先述したメディアサーバーを使用したいところですが、今回は単純なストリーミング機能のみを提供できれば良いので、NGINX をメディアストリーミングサーバーとして使うことができる nginx-rtmp-module を使います。

Docker で NGINX を立てる

nginx-rtmp-module を追加してコンパイルした NGINX の Docker イメージを作ります。ベースイメージには Alpine Linux を使います。ここでは RTMP 用に 1935 ポートと Flash アプリケーションを読み込むための HTML を返すために HTTP 80 ポートを開けるようにします。

Dockerfile を作成する

下記の Dockerfile では、コンパイルに必要なパッケージを apk でインストールして、任意のバージョンの NGINX と nginx-rtmp-module の Tarball をダウンロードし、コンパイルしています。 ./configure のパラメータがやたら多いですが、必要ないモジュールを除外しているだけです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
FROM alpine:3.4
ENV NGINX_VERSION nginx-1.11.4
ENV NGINX_RTMP_MODULE_VERSION 1.1.7.10
ENV USER nginx
RUN adduser -s /sbin/nologin -D -H ${USER}
RUN apk --update --no-cache \
add ca-certificates \
build-base \
openssl \
openssl-dev \
pcre-dev \
&& \
update-ca-certificates && \
rm -rf /var/cache/apk/*
RUN mkdir -p /tmp/build/nginx && \
cd /tmp/build/nginx && \
wget -O ${NGINX_VERSION}.tar.gz https://nginx.org/download/${NGINX_VERSION}.tar.gz && \
tar -zxf ${NGINX_VERSION}.tar.gz
RUN mkdir -p /tmp/build/nginx-rtmp-module && \
cd /tmp/build/nginx-rtmp-module && \
wget -O nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION}.tar.gz https://github.com/sergey-dryabzhinsky/nginx-rtmp-module/archive/v${NGINX_RTMP_MODULE_VERSION}.tar.gz && \
tar -zxf nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION}.tar.gz && \
cd nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION} && \
wget -O - https://raw.githubusercontent.com/gentoo/gentoo/6241ba18ca4a5e043a97ad11cf450c8d27b3079f/www-servers/nginx/files/rtmp-nginx-1.11.0.patch | patch
RUN cd /tmp/build/nginx/${NGINX_VERSION} && \
./configure \
--sbin-path=/usr/local/sbin/nginx \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--pid-path=/var/run/nginx/nginx.pid \
--lock-path=/var/lock/nginx/nginx.lock \
--user=${USER} --group=${USER} \
--http-log-path=/var/log/nginx/access.log \
--http-client-body-temp-path=/tmp/nginx-client-body \
--with-http_ssl_module \
--with-http_gzip_static_module \
--without-http_userid_module \
--without-http_access_module \
--without-http_auth_basic_module \
--without-http_autoindex_module \
--without-http_geo_module \
--without-http_map_module \
--without-http_split_clients_module \
--without-http_referer_module \
--without-http_proxy_module \
--without-http_fastcgi_module \
--without-http_uwsgi_module \
--without-http_scgi_module \
--without-http_memcached_module \
--without-http_limit_conn_module \
--without-http_limit_req_module \
--without-http_empty_gif_module \
--without-http_browser_module \
--without-http_upstream_hash_module \
--without-http_upstream_ip_hash_module \
--without-http_upstream_least_conn_module \
--without-http_upstream_keepalive_module \
--without-http_upstream_zone_module \
--without-http-cache \
--without-mail_pop3_module \
--without-mail_imap_module \
--without-mail_smtp_module \
--without-stream_limit_conn_module \
--without-stream_access_module \
--without-stream_upstream_hash_module \
--without-stream_upstream_least_conn_module \
--without-stream_upstream_zone_module \
--with-threads \
--with-ipv6 \
--add-module=/tmp/build/nginx-rtmp-module/nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION} && \
make -j $(getconf _NPROCESSORS_ONLN) && \
make install && \
mkdir /var/lock/nginx && \
mkdir /tmp/nginx-client-body && \
rm -rf /tmp/build
RUN apk del build-base openssl-dev && \
rm -rf /var/cache/apk/*
RUN ln -sf /dev/stdout /var/log/nginx/access.log && \
ln -sf /dev/stderr /var/log/nginx/error.log
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY build /var/www/build
RUN chmod 444 /etc/nginx/nginx.conf && \
chown ${USER}:${USER} /var/log/nginx /var/run/nginx /var/lock/nginx /tmp/nginx-client-body && \
chmod -R 770 /var/log/nginx /var/run/nginx /var/lock/nginx /tmp/nginx-client-body
EXPOSE 80
EXPOSE 1935
CMD ["nginx"]

NGINX の configuration を設定する

イメージにコピーする nginx.conf は下記のように設定します。 http コンテキストに加えて nginx-rtmp-module で使用できるようになった rtmp コンテキストに設定を追加しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
user nginx;
worker_processes 1;
daemon off;
events {
...
}
http {
...
}
rtmp {
server {
listen 1935;
listen [::]:1935 ipv6only=on;
application live {
live on;
record off;
}
}
}

この rtmp コンテキストの設定により、ローカルに Docker コンテナを立ち上げたとき rtmp://localhost:1935/live という URL で RTMP サーバに接続が可能になります。

RTMP プレイヤーを実装する

次に RTMP プレイヤーとそれを表示する HTML を作成します。RTMP プレイヤーは Flash アプリケーションとして実装するので ActionScript で書きます。まず新規ファイル Player.as を作成します。

1
$ touch Player.as

Player.asPlayer クラスを作成します。動画を表示するための Video オブジェクトも追加したいので、 Sprite クラスを継承しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package {
import flash.display.Sprite;
import flash.display.StageScaleMode;
import flash.display.StageAlign;
import flash.events.Event;
import flash.events.NetStatusEvent;
import flash.net.NetConnection;
import flash.net.NetStream;
import flash.media.Video;
import flash.external.ExternalInterface;
[SWF(backgroundColor="0x000000")]
public class Player extends Sprite {
private var nc: NetConnection;
private var ns: NetStream;
private var video: Video;
function Player() {
}
}
}

次に Stage の設定します。Flash コンテンツを左上に整列する設定だけします。

1
2
3
4
5
6
7
8
function Player() {
setupStage();
}
private function setupStage(): void {
stage.scaleMode = StageScaleMode.NO_SCALE;
stage.align = StageAlign.TOP_LEFT;
}

NetConnection クラスを使い、クライアントとサーバー間の双方向の接続を作成するための準備をします。 NetConnection オブジェクトのステータスが変化したタイミングで、メディアサーバーからのデータを再生できるように、 NetStream クラスを使ってストリームチャネルを開きます。開いた後ライブストリームを再生するために ns.play() メソッドを実行します。このときストリーム名として "test" を渡していますが、これは後程ライブストリームを作成する際にも使う名前になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Player() {
...
setupNetConnection();
}
private function setupNetConnection(): void {
nc = new NetConnection();
nc.addEventListener(NetStatusEvent.NET_STATUS, onChangeNCStatus);
}
private function onChangeNCStatus(e: NetStatusEvent): void {
const code: String = e.info.code;
if (code === "NetConnection.Connect.Success") {
setupNetStream();
ns.play("test");
}
}

作成したストリームチャネルからの動画を表示するために Video オブジェクトに取り付けます。

1
2
3
4
5
6
private function setupNetStream(): void {
ns = new NetStream(nc);
ns.addEventListener(NetStatusEvent.NET_STATUS, onChangeNSStatus);
video.attachNetStream(ns);
}

この Video オブジェクトもコンストラクト時に作成し、ステージに追加してきます。

1
2
3
4
5
6
7
8
9
function Player() {
...
ssetupVideo();
}
private function setupVideo(): void {
video = new Video(stage.width, stage.height);
addChild(video);
}

NetConnection の準備が整ったので、最後にメディアサーバーに接続します。URL は先程の NGINX の RTMP メディアサーバーの live application に向けています。

1
2
3
4
function Player() {
...
nc.connect("rtmp://localhost:1935/live");
}

ActionScript のコンパイル

これで RTMP サーバーの実装はできたので、次は ActionScript をコンパイルします。コンパイルには Apache/Adobe Flex SDK の Node.js モジュール版である node-flex-sdk を使用します。まずは NPM でインストールします。

1
$ npm i flex-sdk --save-dev

無事インストールできたら、mxmlc というコマンドを使って、 Player.as から Player.swf をコンパイルします。

1
$ $(npm bin)/mxmlc --output=Player.swf Player.as

HTML の作成

作成された Player.swf を表示する HTML を作成します。

1
<object data="./Player.swf" type="application/x-shockwave-flash"></object>

Docker コンテナの起動

Docker コンテナの構成に必要なファイルが揃ったので、これでイメージを作成します。

1
$ docker build -t rtmp .

イメージが作成できたか確認しましょう。

1
2
3
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
rtmp latest ************ About a minute ago 180.7 MB

無事に作成できたら、そのイメージから Docker コンテナを起動します。

1
$ docker run -p 1935:1935 -p 80:80 --name rtmp -t rtmp

Web ブラウザで http://localhost/ にアクセスしてみましょう。

RTMP プレイヤー

黒いボックスが表示されたと思います。このボックスが RTMP プレイヤーなのですが、今は配信するストリームが存在していないため、何も再生できず黒い状態です。ですので、次は再生するストリームを作成します。

ストリームの作成

ここでは、Open Broadcaster Software (OBS)というオープンソースのライブストリーミング用のツールを使用してストリームを作成します。

OBS を起動して、「Settings」ボタンをクリックします。

OBS

「Settings」ダイアログが表示されるので、左側のペインから「Stream」を選択します。 すると「URL」と「Stream key」を入力する画面に切り替わりますので、「URL」に NGINX の RTMP サーバーの live application の URL を入力し、「Stream key」には先程 RTMP プレイヤーを実装したときにストリーム名として指定した test を入力します。

OBS Settings

「Settings」ダイアログで「OK」をクリックしたら、次に「 Sources」の「+」をクリックして適当なメディアソースを追加します。プルダウンメニューが表示されるので「Media Source」を選択して任意の動画ファイルを追加します。

メディアソースが追加されたら、「Start Streaming」ボタンをクリックしてストリーミングを開始します。

OBS Sources

ストリーミングが開始されたら、Web ブラウザに戻ります。すると OBS でストリームしている動画がブラウザの方でも再生されていることが確認できます。

RTMP 再生

RTMP でメディアサーバーのメソッドを呼ぶ

ここまでで RTMP でサーバー側からプッシュされたデータをクライアントで再生する実装をしてきました。しかし、RTMP は双方向のデータ通信が可能なので、クライアント側からサーバー側のメソッドを呼ぶことも可能です。 nginx-rtmp-module では難しいですが、メディアサーバーに Adobe Media Server や Wowza Media Server を利用して開発をした場合、クライアント側からサーバー側のメソッドを呼ぶことが可能です。

ActionScript の場合、クライアントとサーバー間の双方向の接続が作成した後( NetConnection オブジェクトが nc.connect() して、 NetStatusEvent"NetConnection.Connect.Success" になった後)であれば、 nc.call() でサーバー側のメソッドを呼ぶことができます。 nc.call() の第1引数がメソッド名なので、 nc.call("doSomething") のようにクライアントから実行した場合、メディアサーバーに実装した該当のメソッドが実行されます。

NetConnection.call

たとえば Wowza Media Server の場合であれば、実装は Java なので下記のようなメソッドを実装することで、クライアントから Wowza Media Server のコンソールに doSomething is called と表示させることが可能です。

1
2
3
4
public void doSomething(IClient client, RequestFunction function, AMFDataList params) {
getLogger().info("doSomething is called");
// do something
}

AbemaTV の生放送番組では、RTMP の双方向通信を利用して、Web ブラウザから Wowza Media Server のメソッドを呼ぶことで、番組の進行具合に合わせて CM 入りのタイミングや視聴者参加型のインタラクションコンテンツのトリガーを最小限の遅延で放送に挿し込んでいます。

RTMP で受け取った動画を HLS でもストリーミング

メディアサーバーはエンコーダーから RTMP 通信で送られた動画をそのまま RTMP でクライアントにプッシュ送信する以外に HLS や MPEG-DASH でストリーミングできるように変換することも可能です。たとえが nginx-rtmp-module の場合は先程作成した nginx.conf を編集して、 application live コンテキストに HLS に関する以下のディレクティブを追加します。

1
2
3
4
5
6
7
8
9
application live {
live on;
record off;
hls on;
hls_path /usr/local/nginx/html/hls;
hls_fragment 1s;
hls_type live;
}

すると、Live セッションの m3u8 プレイリストと 1 秒感覚のセグメントファイルを /usr/local/nginx/html/hls/live に出力してくれます。この nginx.conf を反映した Docker コンテナが起動している状態で、http://localhost/hls/test.m3u8 に Safari でアクセスすると HLS でストリーミング再生ができます。(Safari でアクセスする理由は前回書いた通り、HLS をネイティブサポートしている Web ブラウザが Safari だけだからです。)

HLS を Safari で再生

まとめ

普段の Web フロントエンドの開発では、RTMP や ActionScript を扱う必要があることはあまりありません。しかし、こと動画やストリーミング領域となるとまだ Flash テクノロジーの安定性にお世話になることも多いように思います。WebRTC や WebSocket などの技術の組み合わせでこのあたりの事情もどんどん変化していきそうです。

参考

フロントエンドエンジニアのための動画ストリーミング技術基礎

AbemaTV という動画サービスをリリースしてから半年経ち、新しくサービスのフロントエンドに関わる人数が少し増えてきたため、動画に関して社内で勉強会を行いました。本記事はその勉強会資料です。

Web でメディアを見るためにはデータのダウンロードが必要

Download via HTTP

Web サービスが HTML を介して提供するコンテンツはテキスト、画像、音声、動画などいろいろありますが、テキスト以外のデータは HTML にインラインで返したりせず、基本的には外部ファイルとして非同期に取得されることがほとんどだと思います。

画像の場合

HTML 内の img 要素の src 属性に表示したい画像ファイルのパスを指定することで、Web ブラウザはその画像をリクエストし、ダウンロードしたデータをデコードして画像として表示します。

1
<img src="sample.jpg" />

https://placekitten.com/g/300/300

動画の場合

動画の場合も同じです。video 要素を使って img 要素と同様に src 属性に動画ファイルのパスを指定します。

1
<video src="sample.mp4"></video>

動画はデータ容量が大きい

画像と違い、動画コンテンツはデータ容量がとても大きいため、データをダウンロードして再生するまでに待ち時間が発生します。

動画のダウンロード

動画のデータ容量が大きい理由はとても単純で、動画は画像データが集合したものだからです。静止画像を人間の目が滑らかに感じられる速さで切り替えて表示することで絵を動かすという表現を実現しています(よくパラパラマンガに例えられますが、そんな感じです)。この人間の目が滑らかに感じる速さというのが 1 秒間に 30 枚だったり 24 枚を切り替えることになります。29.97 (≒30) fps とか 24 fps とかの数字を耳にしたことがあるかと思いますが、24 fps の場合は 1 秒間(s)の間(p)に 24 フレーム(f)を切り替えることを意味します。

データを全て自分の端末にダウンロードしてから再生しようとすると、かなり長い待ち時間が発生してしまいます。もし 2 時間の映画を見ようと思ったら 172,800 (= 24 フレーム * 60 秒 * 60 分 * 2 時間) 枚の画像をダウンロードするのを待つことになります。しかも動画を構成する要素は画像だけではなく、音声データも含まれるため、純粋な情報量としてはそれ以上になります。

ストリーミング

動画データを全てダウンロードしてから再生するのではなく、ダウンロードしたデータで再生できる部分から再生を始め、同時に残りのデータをダウンロードしていく方式を、ストリーミング再生といいます。長時間の動画でもダウンロードしながら再生することができるので、再生するまでの待ち時間を短かくすることができます。

また、ストリーミングでは動画を途中から再生することも可能にします。2 時間映画のたとえば 1 時間経ったあたりから見たいとき、1 時間経過した部分からデータをダウンロードし始め、再生を始めることができます。

シーク

AbemaTV で使用しているストリーミングプロトコル

ストリーミング再生は、映像を配信する側と映像を再生する側で、データをどのような手順で通信するかをあらかじめ決めて、その手順通りに両者がデータを処理することによって実現します。その通信手順のことをストリーミングプロトコルと呼びます。ここでは AbemaTV で使用しているはストリーミングプロトコルを 2 つ説明します。

HTTP Live Streaming

HTTP Live Streaming はアップル社が自社プロダクトである QuickTime、OS X、iOS、Safari 向けに開発したストリーミングプロトコルです。略して HLS と呼ばれるので、この記事でも HLS と表記します。その名前の通り、通信は HTTP で行われます。専用のプロトコルが必要ないため、通常の Web サーバーを用意するだけで配信ができてしまいます。

HLS

HLS を配信するために必要なファイルは、動画を数秒ごとの「MPEG-2 TS」形式のファイルに分割したセグメントファイル、それらをどの順番で再生するかを記したプレイリストだけです。

m3u8 ファイルと ts ファイル

簡単な HLS の配信を試してみる

まず、プレイリストとセグメントファイルを作成します。ここでは ffmpeg というツールを使い、 input.mp4 というファイル名で保存されている動画から output.m3u8 というプレイリストと分割されたセグメントファイルを作成します。

1
2
3
4
5
6
7
8
9
$ ffmpeg -i input.mp4 \
-vcodec libx264 \
-s 1280x720 \
-acodec aac -b:a 256k\
-flags +loop-global_header \
-bsf h264_mp4toannexb \
-f segment -segment_format mpegts \
-segment_time 10 \
-segment_list output.m3u8 output_%04d.ts

すると、 output.m3u8output_****.ts というファイルが作成されます。 output.m3u8 の内容は下記のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:18
#EXTINF:10.500000,
output_0000.ts
#EXTINF:12.625000,
output_0001.ts
#EXTINF:10.416667,
output_0002.ts
#EXTINF:10.416667,
output_0003.ts
...略
output_0058.ts
#EXTINF:5.125000,
output_0059.ts
#EXT-X-ENDLIST

Web サーバーを起動します。ここでは Mac OS X にプリインストールされている Python2 を使用します。

1
$ python -m SimpleHTTPServer

Python2 の SimpleHTTPServer モジュールはデフォルトで 8000 番ポートを使用するので、Safari で http://localhost:8000/output.m3u8 にアクセスします。

Safari で HLS を再生

ここでアクセスする Web ブラウザに Safari を指定しているのは、Safari 以外のメジャーブラウザでは HLS をネイティブサポートしていないためです。Safari 以外のブラウザで HLS を再生するには、Flash などのプラグインを使用するか、後述する Media Source API を使用して、JavaScript で追加実装する必要があります。HLS はアップル社が開発したということもあり、Safari だけは m3u8 をロードしてそのまま再生することができます。

MPEG-DASH

MPEG-DASH は HLS と同様に通信に HTTP を使用したストリーミングプロトコルです。DASH は Dynamic Adaptive Streaming over HTTP の略です。Apple 社が開発した HLS のほかに Microsoft 社が開発した Smooth Streaming や Adobe が開発した HTTP Dynamic Streaming など HTTP ベースのストリーミングプロトコルがいくつかありますが、残念ながら各々互換性がありません。MPEG-DASH は ISO 国際標準規格 (ISO/IEC 23001-6) としてリリースされています。

MPEG-DASH も HLS 同様、通常の Web サーバーと動画のセグメントファイルとプレイリストを用意するだけで配信ができてしまいます。MPEG-DASH ではセグメントファイルは fragmented mp4 もしくは ts 形式、プレイリストは MPD(Media Presentation Description)と呼ばれる XML で記述されたファイルを用意します。

MPD

簡単な MPEG-DASH の配信を試してみる

MPEG-DASH 用のセグメントファイルとプレイリストを用意します。今回はセグメントファイルは fragmented mp4 を使用することにします。まず、ffmpeg を使って動画を fragmented mp4 で映像の圧縮に使う「H.264/AVC」と音声の圧縮に使う「AAC」というコーデックでリエンコードします。コーデックについては後述します。

1
2
3
4
5
6
7
8
9
10
11
12
$ ffmpeg -i ./input.mp4 \
-vcodec libx264 \
-vb 500k \
-r 30 \
-x264opts no-scenecut \
-g 15 \
-acodec aac \
-ac 2 \
-ab 128k \
-frag_duration 5000000 \
-movflags frag_keyframe+empty_moov \
./encoded.mp4

次に MP4Box というツールを使って、動画を分割してセグメントファイルとプレイリストを作成します。

1
2
3
4
5
6
$ MP4Box -frag 4000 \
-dash 4000 \
-rap \
-segment-name sample \
-out ./output.mp4 \
./encoded.mp4

プレイリスト output.mpdoutput.m4s と連番になったセグメントファイル郡が作成されます。 output.mpd は下のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.6.1-revrelease at 2016-09-29T12:57:43.136Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H9M56.466S" maxSegmentDuration="PT0H0M4.000S" profiles="urn:mpeg:dash:profile:full:2011">
<ProgramInformation moreInformationURL="http://gpac.sourceforge.net">
<Title>./output.mpd generated by GPAC</Title>
</ProgramInformation>
<Period duration="PT0H9M56.466S">
<AdaptationSet segmentAlignment="true" maxWidth="320" maxHeight="180" maxFrameRate="30" par="16:9" lang="und">
<ContentComponent id="1" contentType="video" />
<ContentComponent id="2" contentType="audio" />
<Representation id="1" mimeType="video/mp4" codecs="avc3.640014,mp4a.40.2" width="320" height="180" frameRate="30" sar="1:1" audioSamplingRate="48000" startWithSAP="1" bandwidth="631708">
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
<SegmentList timescale="1000" duration="4000">
<Initialization sourceURL="outputinit.mp4"/>
<SegmentURL media="output1.m4s"/>
<SegmentURL media="output2.m4s"/>
<SegmentURL media="output3.m4s"/>
...略
<SegmentURL media="output149.m4s"/>
<SegmentURL media="output150.m4s"/>
</SegmentList>
</Representation>
</AdaptationSet>
</Period>
</MPD>

再び Web サーバーを起動します。

1
$ python -m SimpleHTTPServer

HLS とは違い、残念ながら MPEG-DASH をネイティブでサポートしている Web ブラウザはありません。後程 Media Source Extensions を説明するときに MPEG-DASH プレイヤーを作成するので、そこで確認したいと思います。

HTML5 で扱うストリーミング

HLS と MPEG-DASH は HTML5 用の JavaScript API である Media Source Extensions を利用することで追加でプラグインをインストールすることなく、ストリーミング再生が可能です。

Media Source Extensions

Media Source Extensions は MSE と呼ばれていますので、本記事でも MSE と表記します。MSE は W3C によって標準化されている HTTP ダウンロードを利用してストリーミング再生するために作られた JavaScript API です。

MSE で扱うメディアデータは、W3C で定められている仕様に従って、短い時間で区切ったデータ構造にセグメント化されている必要があります。MSE では、セグメントを 2 種類に分けて扱います。

  • 初期化に必要なヘッダ情報である初期化セグメント
  • 短い時間で区切られたメディアデータ本体が含まれるメディアセグメント

MSE は最初に初期化セグメント、その後にメディアセグメントを順番にソース・バッファに渡すと、そのメディアセグメントの順番で再生していきます。

MSE で簡単な MPEG-DASH プレイヤーを作成してみる

ここでは XMLHttpRequestMediaSource API を使用して簡単な MPEG-DASH プレイヤーを作成して、先程 ffmpeg と MP4Box で作った MPEG-DASH コンテンツを再生してみます。

最初に id をつけた video 要素を用意します。

1
<video id="video"></video>

次に XMLHttpRequest で MPD を取得します。MPD は XML ファイルなので、パースして Representation 要素から MIME タイプやコーデックの情報を取得しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var type, mpd;
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://localhost:8000/output.mpd", true);
xhr.responseType = "document";
xhr.overrideMimeType("text/xml");
xhr.onload = e => {
const mpd = xhr.responseXML;
const representation = mpd.getElementsByTagName("Representation")[0];
const mimeType = representation.getAttribute("mimeType");
const codecs = representation.getAttribute("codecs");
type = `${mimeType}; codecs="${codecs}"`
mpd = mpd;
initializeVideo(); // 次の関数へ
};
xhr.send(null);

次に MediaSource API で最初に用意した video 要素を拡張し、ソースとしてダウンロードした動画のセグメントを追加できるようにします。

1
2
3
4
5
6
7
8
9
var mediaSource;
function initializeVideo() {
mediaSource = new MediaSource();
const video = document.getElementById("video");
mediaSource.addEventListener("sourceopen", initializeSourceBuffer, false); // mediaSource が開いたらソース・バッファを作成する
video.src = URL.createObjectURL(mediaSource);
}

ソース・バッファを作成し、初期化情報が入ったセグメントとメディア本体のセグメントを追加できるように準備します。

1
2
3
4
5
6
7
var sourceBuffer;
function initializeSourceBuffer() {
sourceBuffer = mediaSource.addSourceBuffer(this.type);
sourceBuffer.addEventListener("updateend", appendMediaSegment, false);
appendInitializationSegment(); // 次の関数へ
}

先に取得した mpd から Initialization 要素の sourceURL の値を取得し、 XMLHttpRequest で取得します。セグメントファイルはバイナリデータなので、 responseTypearraybuffer に指定しておきます。

1
2
3
4
5
6
7
8
function appendInitializationSegment() {
const xhr = new XMLHttpRequest();
const url = mpd.getElementsByTagName("Initialization")[0].getAttribute("sourceURL");
xhr.open("GET", `http://localhost:8000/media/${url}`, true);
xhr.responseType = "arraybuffer";
xhr.onload = appendSegment;
xhr.send(null);
}

そしてセグメントをロードしたタイミングでソース・バッファに追加します。

1
2
3
function appendSegment(e) {
sourceBuffer.appendBuffer(e.target.response);
}

初期化情報がバッファに追加されソースが更新されたら、続けてメディア本体のセグメントファイルを取得し、ソース・バッファに追加します。この処理をメディアセグメントの数だけ繰り返します。

1
2
3
4
5
6
7
8
9
10
var segmentIndex = 0;
function appendMediaSegment() {
const xhr = new XMLHttpRequest();
const url = mpd.getElementsByTagName("SegmentURL")[segmentIndex++].getAttribute("media");
xhr.open("GET", `http://localhost:8000/media/${url}`, true);
xhr.responseType = "arraybuffer";
xhr.onload = appendSegment;
xhr.send(null);
}

メディアセグメント - 動画とは何か

HLS や MPEG-DASH などのストリーミング配信では、セグメントファイルが実際の動画データになります。本記事の最初に書いた通り、動画データの容量は大きいです。ストリーミング配信の仕組みだけでは、動画の再生開始までの待ち時間は短かくすることはできても、再生を続けるために必要な1秒あたりのデータ量は減らすことはできません。ストリーミング再生では 1 秒あたりに必要なデータ量を少なくとも 1 秒以内に取得し続ける必要があります。でないと再生を継続できません。

同じ情報量を表現するデータの容量を小さくしたい場合、データに圧縮処理をかけます。圧縮のアルゴリズムはいくつもありますが、動画は映像と音声で構成されているため、映像の圧縮に適したアルゴリズムと音声の圧縮に適したアルゴリズムは異なることを考慮する必要があります。映像圧縮に適したアルゴリズムで処理した映像ファイルと音声圧縮に適したアルゴリズムで処理した音声ファイルを1つのファイルとしてまとめたものが動画ファイルです。

コンテナとコーデック

動画ファイルは映像ファイルと音声ファイルをまとめたものと説明しましたが、このまとめ方の形式のことをコンテナフォーマットといいます。また、映像データや音声データを圧縮するアルゴリズムのことをコーデックといいます。

コンテナとコーデック

コンテナ

コンテナフォーマットは一般的にコンテナと略します。コンテナと呼ぶと難しそうですが、コンテナはファイルフォーマットの1種なので、私たちが普段動画ファイルとして意識している単位と一致します。ファイルフォーマットとはファイルの保存形式のことです。以下にリストしたものが代表的なコンテナですが、聞いたことがある名前が多いと思います。

  • AVI
  • MP4
  • MOV
  • MPEG
  • MKV
  • WMV
  • FLV
  • ASF

コンテナは映像と音声データがどのように格納されるのかを定義しています。また動画は映像と音声を同時に再生する必要があるため、両者の同期を取るための情報もコンテナが格納しています。ほかにも動画タイトルや説明などのメタ情報、字幕などの情報もコンテナが格納されている場合があります。

コンテナは対応しているコーデックの映像と音声データのみ格納することができます。1つのコンテナがいくつかのコーデックに対応している場合も多々あるので、コンテナの種類が分かっても格納されているコーデックの種類は分かりません。そのため、動画プレイヤーが同じコンテナで保存された2つの動画ファイルのうち、片方だけ再生できるということもあります。

たとえば Flash 動画のコンテナである FLV は映像コーデックとして「Sorenson Spark」と「H.264/AVC」を格納できます。もし動画プレイヤーが「Sorenson Spark」には対応していても 「H.264/AVC」には対応していなかった場合、「Sorenson Spark」を格納している FLV ファイルは再生できても、「H.264/AVC」を格納している FLV ファイルはコーデックエラーが発生して再生できません。

コーデック

映像や音声は圧縮する必要があります。特にストリーミング再生などのデータ通信と再生を同時に行うような場合は必須です。コーデックはその圧縮のアルゴリズムです。

なぜ映像を圧縮する必要があると言うと、映像はたくさんの静止画をパラパラマンガのようにめくって人間の目に物体や背景が動いているように見せているので、このたくさんの静止画は情報量として膨大なのです。

映像を構成する画像データはラスタという色のついたピクセルの集合で表現します。1 ピクセルの情報量は 24 bit で表現できます(24bit フルカラーの場合、R -赤- G -緑- B -青- の各色成分につき 256 段階の指定ができるため、1 ピクセルは Math.log2(256 * 256 * 256) = 24 bit の情報量が必要)。

そうすると例えば、フル HD の 1 フレームを構成する 1920 * 1080 ピクセルの情報量は 49,766,400 (= 24 * 1920 * 1080) bit になります。これはまだ 1 フレームなので、24 fps の動画の場合、1 秒間に 1,194,393,600 (= 49,766,400 * 24) bit が必要になります。

これは 1 秒間に 1,194 Mbit のデータを通信を介して取得する必要があるということになります。しかし、例えば受信実効速度が 76.6Mbps と記載されているソフトバンク提供の超高速データ通信サービス SoftBank 4G LTE でデータ通信をした場合でも、 1 秒間に取得できるデータ量は 76.6 Mbit なので、先程の 1,194 Mbit に遠く及びません。

しかし、この 1,194 Mbit の映像データは「H.264/AVC」というコーデックで圧縮した場合、典型的な圧縮率としては 1/100 のデータ量に圧縮することができます。すると 12 Mbit 程度になるので、76.6 Mbps のデータ通信速度でも視聴が可能になります。

この「H.264/AVC」は AbemaTV でも映像コーデックとして使用していますが、映像コーデックにはほかにも以下のような種類があります。

  • H.265
  • VP8
  • VP9
  • MPEG-4
  • WMV9

ここでは映像コーデックしか取り上げませんが、音声コーデックは代表的なものに「AAC」や「MP3」があり、AbemaTV では「AAC」を使用しています。

コーデックはデータ量を圧縮するものですが、ただデータ量を減らせればいいのではなく、人間が知覚できる範囲の画質や音質を落とすことなく圧縮しなくてはいけません。なので、選択するコーデックが悪いと画質や音質を落とすことになります。

AbemaTV で使用しているコンテナ MPEG-2 TS

「MPEG-2 TS」は MPEG-2 システムのうち放送・通信用のコンテナです。地上波デジタル放送でも使用されているコンテナですが、HLS でも「MPEG-2 TS」を使用します。DevTools の Network パネルを開いた状態で AbemaTV の動画を視聴しているとたくさんの **.ts という拡張子のデータがリクエストされるのが確認できます。これが「MPEG-2 TS」のファイルです。

「MPEG-2 TS」は放送・通信用に作られたコンテナのため、通信途中でデータが途切れたとしてもちゃんと再生できるように設計されています。「MPEG-2 TS」では動画を 184 バイト単位のデータに分割し、それに 4 バイトの TS ヘッダと呼ばれるデータを付加して計 188 バイト固定長のパケットを連続で転送することでデータ伝送を行います。4 バイトの TS ヘッダのうち最後の 4bit は巡回カウンターと呼ばれるデータを持っていて、これがパケットごとに 1 ずつカウンターするため、これを検査することでパケットの欠落がないかを確認できるようになっています。

TS パケット

MPEG-2 システムには蓄積メディア用のコンテナとして別に「MPEG-2 PS」がありますが、こちらはデータが連続していることが前提なので、ランダムアクセスなどに優れた設計になっています。

AbemaTV で使っている映像コーデック H.264/AVC

AbemaTV では「MPEG-2 TS」コンテナに「H.264/AVC」コーデックで圧縮した映像データを格納しています。「H.264/AVC」は正式名称を「H.264」もしくは「MPEG-4 Part 10 Advanced Video Coding」といいます。(正式名称が2つあるのは ITU-T と ISO/IEC という2つの組織が共同で策定したものをそれぞれの名称をつけているだけです。)「MPEG-4」という名前が付けられている通り、その圧縮アルゴリズムの原理は、従来方式の「MPEG-1」、「MPEG-2」を継承しています。ここでは「MPEG」の圧縮アルゴリズムの原理を学んでいきます。

圧縮の基本

データを圧縮する基本は

  • 出現するデータパターンに偏りを持たせること
  • 出現頻度が高いパターンを短く表現すること

です。単純な例で見ていきます。

出現頻度が高いパターンを短く表現する

たとえば、文字 a-d があったとき、それらを識別する符号を下記のように表現できます。

文字 符号
a 00
b 01
c 10
d 11

文字列「bbabcbdbaacba」は「01 01 00 01 10 01 11 01 00 00 10 01 00」という符号で表現されます。この文字列を表現するのに必要なデータ量は 26(=2*13)bit です。この文字列にて、各々の文字の出現回数は均一ではありません。

文字 出現回数 出現率
a 4 0.31
b 7 0.54
c 2 0.15
d 1 0.08

そこで出現回数が 1 番多い b に 1 番短い符号、2 番目に多い a に次に短い符号を割り当ててみます。

文字 符号
a 10
b 0
c 110
d 111

すると先程の文字列「bbabcbdbaacba」は「0 0 10 0 110 0 111 0 10 10 110 0 10」と表現されますが、データ量が 23bit に減りました。このように、データの出現頻度が均一ではなく偏りがあると、異なる長さの符号を割り当てることによりデータ量を圧縮することができます。

このように可変長の符号を出現頻度に応じて割り当てることエントロピー符号といいますが、その割り当てパターンを作成する方法の 1 つにハフマン符号があります。

ハフマン符号

この図のように出現確立が高いものからツリー上に符号を割り当てていきます。これにて全ての文字が一意かつ瞬時に解読できる少ないデータ量の符号を作成することができます。

出現するデータパターンに偏りを持たせる

一見出現率に偏りがない場合でも情報の表現方法を変えることでデータの出現頻度に偏りを持たせることができます。

たとえば、「1 2 3 2 1 0 -1 -2」のような数列はそのままだと下記のような出現回数ですが、

数字 出現回数 出現率
-2 1 0.125
-1 1 0.125
0 1 0.125
1 2 0.25
2 2 0.25
3 1 0.125

これを前の数字との差分として表現すると「1 2 3 2 1 0 -1 -2」→「0 +1 +1 -1 -1 -1 -1 -1」となり、データの出現頻度に大きな偏りを作ることができました。

差分 出現回数 出現率
0 1 0.125
+1 2 0.25
-1 5 0.625

これをハフマン符号することでデータを圧縮することができます。

MPEG の圧縮

文字列や数列データの圧縮の例について見てきましたが、動画圧縮の場合も基本的な考え方は同様です。しかし、MPEG の場合は動画特有の性質を利用して圧縮率を高める工夫をしています。

MPEG の圧縮アルゴリズムは静止画の圧縮と映像の圧縮で構成されています。

  • 静止画自体のデータサイズを圧縮する
  • 連続する映像フレームのデータの差分だけを記録する

静止画の圧縮

MPEG の静止画の圧縮アルゴリズムの基礎は画像圧縮規格である「JPEG」です。**.jpg の拡張子で馴染のアレです。

静止画の圧縮アルゴリズムは簡単に以下のようなことを行います。

  • 画像は隣り合うピクセルが似ているという特徴を利用して差分情報だけで表現する
  • 人間の目が変化に鈍感な情報を省略する
  • エントロピー符号する

画像は隣り合うピクセルが似ている

たとえば空の写真を撮影した場合、その画像を構成するピクセルの多くは空の青色と雲の白色の微妙な色味の変化になると思います。空ほど色数が少なくない写真や絵の場合でも、基本的に画像は色が段階的にしか変化していないピクセルの方が出現頻度が圧倒的に多く、急な変化の頻度は少ないはずです。

空

この画像の性質を利用して、画像データの出現頻度に偏りを作って符号化することを DPCM 符号化といいます。

人間の目が変化に鈍感な情報を省略する

MPEG は離散コサイン変換という演算を行うことで、人間の目にあまり目立たない細かい情報をデータから取り除いてしまうことで圧縮率を上げています。離散コサイン変換は英語では Discrete Cosine Transform というので DCT と略されます。

DCT では画像を波形として扱い、フーリエ変換のように周波数ごとの波の強度で画像を表現します。ここで高い周波数の波は人間の目にあまり目立たない情報となるので、省略してしまうことで画質への影響を最小限に抑えながら圧縮率を高めることが可能になります。

DCT

エントロピー符号

ここまで静止画の圧縮について、DPCM 符号化と DCT の処理を見てきましたが、これらで求められた値をエントロピー符号することで更に圧縮効率を高めます。

映像の圧縮

静止画の圧縮では、映像における 1 枚 1 枚のフレームのデータ量を削減しました。映像の圧縮では、時間の流れを利用してデータの圧縮率を高める工夫をしています。

画素の省略

MPEG では画素情報を RGB ではなく、輝度信号(Y)色差信号(Cr)(Cb) で表現します。RGBの各成分、輝度信号(Y)、色差信号(Cr)(Cb)の関係は下記です。

1
2
3
Y = 0.299*R + 0.587*G + 0.114*B
Cr = 0.500*R - 0.419*G - 0.081*B
Cb = -0.169*R - 0.332*G + 0.500*B

人間の目は明るさの変化に対しての方が色の変化に対してより敏感です。MPEG ではその人間の視覚の癖を利用し、フレームごとに Cr と Cb 信号を画素の情報から省いています。Cr と Cb が少々省かれたとしても 明るさの情報である Y が省かれていなければ、人はそれ程違和感を感じないのです。

CrCb の省略

フレーム間予測

静止画の場合と似ていますが、映像の場合も時間的に隣合うフレームが持つ画像は似ているはずです。MPEG はその映像の特徴を利用して、映像のフレームにその画像を表示するための全ての情報を持たせません。MPEG には 3 種類のフレームがあります。

  • I ピクチャ
  • P ピクチャ
  • B ピクチャ

I ピクチャを除いて、他のフレームが持ってる情報と自身が持ってる情報を合わせて画像を表示することができるようになります。この 3 種類はそれぞれ役割りが違います。I ピクチャは画像を表示するための全ての情報を持っています。P ピクチャは過去に表示したI ピクチャもしくはP ピクチャが持っていたデータとその差分データを使用して画像を表示します。B ピクチャは過去だけではなく未来のI ピクチャもしくはP ピクチャが持っているデータを利用することでより圧縮率を高めます。

I/P/B ピクチャ

まとめ

動画は昔からある技術分野ですが、Web のフロントエンドエンジニアだった自分には足を踏み込んだら分からないことだらけの難しい分野だと感じました。しかし、最近はストリーミング関連の技術も進み、Web においても動画を扱った事業に関わることが増えてきています。本記事は社内勉強会向けですが、フロントエンドエンジニア視点から動画を学んでいくスタートポイントになればと思います。

参考

HTTP Live Streaming

https://en.wikipedia.org/wiki/HTTP_Live_Streaming

H.264

https://ja.wikipedia.org/wiki/H.264

MPEG-2システム

https://ja.wikipedia.org/wiki/MPEG-2%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0

HLSとは

http://qiita.com/STomohiko/items/eb223a9cb6325d7d42d9

ffmpeg で mp4 をiPhone用のストリーミング(HLS)に対応させる。

http://takuya-1st.hatenablog.jp/entry/2016/04/06/034906

MPEG DASHを知る

http://qiita.com/gabby-gred/items/c1a3dbe026f83dd7e1ff

MPEG-DASH content generation with MP4Box and x264

https://bitmovin.com/mp4box-dash-content-generation-x264/

Media Source Extensionsを使ってみた (MP4編) http://qiita.com/tomoyukilabs/items/54bd151aba7d3edf8946

動画・音声の規格について ~コーデック・コンテナ~

http://michisugara.jp/archives/2011/video_and_audio.html

VIDEO-ITを取り巻く市場と技術

http://www.mpeg.co.jp/libraries/video_it/index.html

動画形式の種類と違い(AVI・MP4・MOV・MPEG・MKV・WMV・FLV・ASF等)【コンテナ】

http://aviutl.info/dougakeisiki-konntena/

【動画が再生できない!?】そんなときに必ず役立つ5つの知識

http://smarvee.com/column/can-not-play/

「映像がH.264/AVCでエンコードされたFLV」を「FLV5」と呼ぶのは間違い

http://goldenhige.cocolog-nifty.com/blog/2009/10/h264avcflvflv5-.html

量子化行列のナゾ~その1

http://www.nnet.ne.jp/~hi6/lab/quantize/

モニタ解像度 図解チャート&一覧 / monitor resolution data sheet&chart

http://www.quel.jp/etc/monitor-size/