Amazon Web Services ブログ

Whooshkaa と Amazon Polly: 視覚と聴覚を組み合わせてパブリッシングの世界を広げる

これは、Robert Loewenthal 氏 (Whooshkaa 社 CEO 兼創立者) のゲストブログ投稿です。

Whooshkaa は、オーストラリアを本拠地とするクリエイティブなオーディオオンデマンドのポッドキャストプラットフォームであり、パブリッシャーや広告主によるオーディエンス到達範囲の拡大を支援しています。当社は、常に新しい製品と手法を試しており、これらを組み合わせてお客様のための新しいソリューションを生み出しています。

Amazon Polly のテキスト読み上げ (TTS) 機能が好例です。当社のお客様の中には、すでに Amazon Polly を使用して既存の配信方法を拡張している大手のパブリッシャー、スポーツ団体、オーストラリア最大の通信会社があります。

これらの従来の情報プロバイダーは、今日の購読者が目だけでなく、耳を通した情報の取得に関心がある点に注目しています。Whooshkaa では、Amazon Polly TTS を使用することで、情報プロバイダーが 48 種類の音声と 24 言語で購読者に情報を提供できます。

今年初めに、オーストラリアを代表する全国紙 The Australian に Amazon Polly が導入されました。購読者は、運転やエクササイズなどで手や目を放せないときに Amazon Polly が読み上げる新聞の記事、レシピ、スポーツの試合結果などを聴くことができます。

Whooshkaa では、Amazon Polly を使用することで、特定のパートナーは選択した任意の新聞記事を数秒以内にポッドキャストエピソードに変換できます。当社が提供するツールでは、複数の記事をマージし、音声をカスタマイズしてアクセント、ピッチ、速度、音量を変更することもできます

Whooshkaa の配信ネットワークは多様であり、ユーザーは様々な手段から選んでコンテンツを再生できます。代表的な手段はお気に入りのポッドキャストアプリを使うことです。Whooshkaa は Facebook と独自の提携をしているため、ポッドキャストエピソードをネイティブのオーディオプレイヤーで再生できます。当社のカスタマイズ可能なウェブプレイヤーは Twitter でもサポートされています。ただし、任意のウェブサイトに埋め込むことができます。

このテクノロジーが充実すれば、世界の地域と言語を問わず、パブリッシャーは新聞記事を自由に提供できるようになります。新聞記事は、読者の設定とニーズに応じてカスタマイズすることもできます。

当社はまた、オーストラリア最大の通信会社 Telstra およびナショナルラグビーリーグと提携し、接続されたスマートスピーカーを通じてユーザーのお気に入りのチームの試合結果をライブ配信しています。ユーザーがデバイスに尋ねるだけで、最新の結果が即座に読み上げられます。

当社の開発者 Christian Carlsson によると、Amazon Polly TTS の即応性と幅広い言語は、あらゆるタイプのパブリッシャーに無限の可能性をもたらします。

「人工知能と Whooshkaa の既存の強力なプラットフォームを統合することで、30 秒未満でテキストから完全な自動ポッドキャストエピソードを作成できるようになりました。しかも、これは始まりにすぎません」と Carlsson は述べています。

AFL 統合の技術的な実装

オーストラリアンフットボールリーグ (AFL) は、ファンがスマートスピーカーに話しかけてお気に入りのチームをフォローできるようにしたいと考えました。そのために、Whooshkaa では RSS フィードを作成し、最新の結果を 2 分ごとに更新する必要がありました。次は、この実装の概略図です。

必要なデータが含まれている AFL の API のクロールをトリガーするために、API を呼び出すシンプルな AWS Lambda 関数を設定しました。Whooshkaa API は、データをフェッチして解析し、テキストを音声に変換して、新しく作成した RSS フィードを Amazon S3 に発行します。

まず、2 分ごとにリクエストを初期化するための serverless.yml ファイルを準備しました。このファイルは特別なものではありません。

Serverless.yml:
createAFLFeeds:
 handler: api.createAFLFeeds
 events:
   - schedule:
       rate: rate(2 minutes)
       enabled: ${self:custom.${opt:stage}.ScheduledEvents}

これにより、次のコードがトリガーされます。

WhooshkaaAPI.js
createAFLFeeds() {
    return new Promise((resolve, reject) => {
      this.fetchAFLTeams().then(result => {
        for (const team of result) {
          this.createAFLFeedByTeamID(team['id']);
        }
      }, error => {
        console.log(error);
        reject(error);
      });
      resolve({message: "success"});
    });
}

次に、 createAFLFeedByTeamID メソッドから POST リクエストがエンドポイントに送信され、エンドポイントで以下の操作が行われます。

  1. AFL API からデータをフェッチします。このメソッドをできるだけ読み取り可能にするために、データの正規化が別の AFL パッケージに抽象化されます。どのデータを解析するかは、いくつかの異なる条件で決まります。チームが試合中であるか過去 24 時間以内に試合をしている場合は、チームの一致するデータが取得されます。それ以外の場合は、デフォルトで、チームの最新のニュースが取得されます。
  2. ハッシュを Amazon S3 に保存することで、返されたデータが最新であることを確認します。$this->publisher は抽象化されたクラスであり、3 つの異なるストレージアダプターとして、ローカル、Whooshkaa S3 バケット、および AFL S3 バケットが含まれます。ローカルアダプターではデータを操作し、Whooshkaa S3 バケットではハッシュを保存します。AFL S3 バケットには、生成した RSS フィードを発行します。
  3. 取得したテキストを Amazon Polly を通じてオーディオストリームに変換します。 makeAudio メソッドは、一部の単語を操作して期待どおりの音声で生成する方法を示しています。たとえば、マジソンスクエアガーデンの略である MCG は「McGee」と解釈されるため、代わりに、これをスペルアウトするよう Amazon Polly に指示します。
  4. RSS フィードを作成して AFL の S3 バケットに発行します。
AFLController.php:
public function team(string $id)
{
    if (!$team = Team::findById($id)) {
        $this->response->errorNotFound('Invalid team ID.');
    }

    if ($team->isPlayingOrHasRecentlyPlayed()) {
        $story = $team->match;
    } else {
        $story = $team->news;
    }

    $this->publisher->setTeamId($id);
    $this->publisher->setStory($story->getStory());

    $hash = Hash::make($story, $this->publisher->getRemoteStorageAdapter());
    if ($hasBeenUpdated = $hash->hasBeenUpdated()) {
        $fileName = $this->publisher->getFileName();

        $audio = $this->makeAudio($story);
        $this->publisher->store($fileName, $audio->getContent());

        $feed = $this->makeFeed($team, $story);
        $this->publisher->store('feed.xml', $feed->getContent());

        $this->publisher->moveToCloud([$fileName, 'feed.xml']);
        $this->publisher->cleanUp();

        $hash->store();
    }

    return response([
        'rss' => $this->publisher->getRemoteUrl('feed.xml'),
        'updated' => $hasBeenUpdated,
    ]);
}

private function makeAudio($story)
{
   $polly = new Polly;
   $polly->setPhonemes(['live' => 'laɪve']);
   $polly->setProsody('AFL', ['rate' => 'fast']);
   $polly->setSayAs(['MCG' => 'spell-out']);

   $text = $story->getStory();
   // Trim the text to a maximum of 1500 characters.
   if (strlen($text) > 1499) {
       $text = $this->text->setText($text)->trimToWordBoundary(1499);
   }

   try {
       $audioStream = $polly->fetchAudioStream($text);
   }
   catch (\Exception $e) {
       $this->response->error($e->getMessage(), $e->getStatusCode());
   }

   return response()->make($audioStream)->header('Content-Type', 'audio/mpeg');
}

private function makeFeed(Team $team, $story)
{
   $feed = new Feed($this->publisher->getRemoteURL('feed.xml'));
   $feed->setTitle($team->getName() . "'s Official Live Feed");
   $feed->setDescription('An official live feed from the Australian Football League.');
   $feed->setLink('http://www.afl.com.au');
   $feed->setOwner('The Australian Football League', 'podcast@afl.com.au');
   $feed->setImage($team->getImage());
   $feed->appendElements([
       'itunes:subtitle' => "Follow {$team->getName()}'s Live Matches and Latest News",
       'itunes:explicit' => 'no',
       'language' => 'en-us',
       'lastBuildDate' => Carbon::now('UTC')->toRssString(),
       'ttl' => 2,
       'copyright' => 'The Australian Football League',
   ]);

   $feed->setCategories([
       'Sports & Recreation' => [
           'Professional',
       ]
   ]);

   $fileName = $this->publisher->getFileName();
   $metaData = $this->getMetaData($fileName);

   $item = $feed->addItem([
       'title' => $story->getTitle(),
       'link' => $story->getArticleURL(),
       'pubDate' => Carbon::now('UTC')->toRssString(),
       'itunes:duration' => $metaData['playtime_string'],
   ]);
   $item->appendDescription($story->getStory());
   $item->appendEnclosure($this->publisher->getRemoteUrl($fileName, true), $metaData['filesize'], $metaData['mime_type']);
   $item->append('itunes:image', null, ['href' => $team->getImage()]);
   $item->append('guid', $this->publisher->getGuid(), ['isPermaLink' => 'false']);

   return response()->make($feed->output())->header('Content-Type', 'text/xml');
}

オーストラリアの「Daily News」の技術的な実装

The Australian は News Corp 傘下の新聞社です。この新聞社は、毎日 10 大ニュースを読者に音声で提供することを考えました。ニュースをポッドキャストエピソードとして 1 日に 5 回更新する必要があります。当社では、Amazon Polly との統合により、この要件を簡単に実装できました。この実装の概略図は次のとおりです。

この実装は AFL の統合と酷似していますが、1 つの例外があります。RSS フィードを生成する代わりに、エピソードの発行先を Whooshkaa のオーストラリアのアカウントの指定されたショーにします。これにより、エピソードはほぼ即座に iTunes、Pocket Casts、またはその他のポッドキャストプレイヤーで再生できます。

この実装をビルドするために、AFL 実装の場合と同じように AWS Lambda 関数をセットアップしました。「Daily News」エンドポイントを毎日の特定の時間にトリガーする必要があるためです。

Serverless.yml
createDailyNewsStory:
 handler: api.createDailyNewsStory
 events:
   - schedule:
       rate: cron(0 2,6,10,22 * * ? *)
       enabled: ${self:custom.${opt:stage}.ScheduledEvents}
   - schedule:
       rate: cron(30 14 * * ? *)
       enabled: ${self:custom.${opt:stage}.ScheduledEvents}
WhooshkaaAPI.js
createDailyNewsStory() {
 const options = {
   hostname: this.commonOptions.hostname,
   port: this.commonOptions.port,
   path: '/news-corp/daily-news',
   method: 'POST',
 };
 return new Promise((resolve, reject) => {
   this.sendRequest(options).then(result => {
     return resolve(result);
   }, error => {
     console.log(error);
     return reject('Could not create "Daily News" story.');
   });
 });
}

次に、 createDailyNewsStory ハンドラで createDailyNewsStory 関数を呼び出し、以下のように、API で dailyNews エンドポイントをトリガーします。

NewsCorpController.php
public function dailyNews()
{
   $show = Show::find(DailyNewsStory::SHOW_ID);
   $storyBuilder = new StoryBuilder($show);

   $dateTime = Carbon::now('Australia/Sydney')->format('F j (g:00 a)');
   $title = $show->title . ' - ' . $dateTime;

   $story = new DailyNewsStory;
   $story->setLimit(10);
   $story->setTitle($title);
   $story->setDescription($title);

   $episode = $storyBuilder->fetch($story)->publish();

   return $this->response->item($episode, new EpisodesTransformer);
}

DailyNewsStory で拡張される StoryBase クラスには、 NewsCorpApi クラスの依存関係が挿入されています。 DailyNewsStory の値は NewsCorpApi クラスに渡されます。ここでデータを取得して正規化します。

次に、取得したすべての記事のオーディオを生成し、1 つのエピソードとして発行します。これは StoryBuilder クラスで次のように行われます。

StoryBuilder.php
public function publish()
{
   $title = $this->story->getTitle();
   $description = $this->story->getDescription();

   if (!$episode = $this->episodes->findByTitleAndDescription($title, $description)) {
       $audio = $this->makeAudio();
       $fileName = $this->storage->putContent($audio->content(), Polly::OUTPUT_FORMAT);

       $data = [
           'podcast_id' => $this->show->id,
           'title' => $title,
           'description' => $description,
           'media_file' => $fileName,
           'length' => $this->storage->getSize($fileName),
       ];

       $episode = $this->episodes->create($data);
   }

   return $episode;
}

public function makeAudio()
{
   $polly = new Polly;

   $audioStream = null;
   foreach ($this->story->getBody() as $body) {
       $audioStream .= $polly->makeAudioStream($body);
   }

   return $polly->makeAudioResponse($audioStream);
}

次に $this->story->getBody() をループさせます。これは配列であり、前述した 10 個すべての記事が含まれているためです。これにより、Amazon Polly から継続的なオーディオストリームが作成されます。次に、オーディオストリームは mp3 ファイルとして S3 バケットにアップロードされ、そのファイル名が残りの情報と共にデータベースに保存されてリクエストで返されます。

当社の多くのお客様は、大量のリッチコンテンツを生成しています。当社が提供するプラットフォームでは、お客様は Amazon Polly を使用してコンテンツをオーディオに変換し、配信、分析、営利化できます。あるニュースパブリッシャーは、Whooshkaa および Amazon Polly のテキスト読み上げを使用してレシピライブラリを提供する予定です。

Whooshkaa では、常にオーディオを使用したイノベーションの方法を追求しています。クリエイターに最も広範な配信ネットワークを提供するために新しい市場とテクノロジーを開拓しています。従来のパブリッシャーと Amazon Polly の組み合わせは極めて有望です。


今回のブログの投稿者について

Robert Loewenthal 氏は、Whooshkaa の CEO 兼創立者です。Whooshkaa は、オーストラリアのシドニーを本拠地とするフルサービスのオーディオオンデマンド企業であり、クリエイターおよびブランドによるコンテンツの生成、ホスト、共有、追跡および収益化を支援しています。