まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

2022/05

ちょっとした目的で実験的に調査&開発していたウェブアプリの話がポシャりそうなので、アプリだけ公開することにしました。

やりたかったのはウェブアプリで
 1 日本語音声を認識して日本語テキスト化して、
 2 そのテキストを形態素解析して単語分解した上で、
 3 単語の出現頻度をタグクラウドで視覚化し、
 4 (ある程度以上の語彙が集まったら)テキスト内容から感情分析を行ってグラフ化する
 5 1~4をリアルタイムに行う(音声認識が途切れるタイミングで全情報を更新する)
というものでした。

技術的には色々な実現手段があるのですが、今回は実験的に作っていた経緯もあるので、
 1 Web Speech API
 2 TinySegmenter
 3 jQCloud
 4 色々使って独自実装
で作っています。

本当は4で IBM Personality Insight API とか使えると簡単でよかったのですが、今回は使っていないことを白状しておきます。また1の実装の都合上、PC 版 FireFox では動きません。後述のサンプルを利用する際には PC の Chrome か  Edge で試していただきたいです(互換性という意味では Android 版 Chrome でも動くことになっていますが、自分のテストでは認識精度がかなり悪かったので PC での利用をお勧めします)。また4の実装の中で利用回数に制約のある無料 API を使っているため、使いすぎて(使われすぎて)限度回数を超えてしまうとその月の間は動かなくなる、という点をご了承ください。

で、作って公開してみたのがこちらです。繰り返しますが PC 版 Chrome でアクセスしてください:
https://dotnsf.github.io/webspeechpi/


アクセスするとこのような画面が表示されます。右上の青いマイクボタンをクリックすると音声認識モードに切り替わります(初回のみマイクアクセスの許可を求められるので「許可」してください):
2022052901


音声認識モードに切り替わると青だったボタンは赤く変わります。またウェブページのタブに音声収集中であることを示す赤いマークが表示されます(ちなみにもう一度赤マイクボタンをクリックすると青マイクボタンに戻り、音声認識モードからも抜けます):
2022052902


この音声認識モードの状態でマイクに日本語で話しかけると、その文章が認識されて表示されます。ある程度の長さの無音状態が続くまでは1つの文章とみなして、たまに内容を変更・調整しながら認識を続けます:
2022052903


ある程度の長さの無音状態を認識すると、それまでに認識した文章を形態素解析し、名詞や動詞、形容詞といった、文章内容の肝となりうる単語がタグクラウドで表示されます:
2022052904


この認識文章の量が少ないとタグクラウドだけが表示されますが、ある程度以上の文章が認識されるとタグクラウドに加えて、そのテキスト内容から話している人の感情を5つの要素で分析して、その結果がレーダーチャートで表示されます。このタグクラウドとレーダーチャートは文章が入力されるごとに更新されます:
2022052905


と、まあこんな感じのものです。もともとはある業務目的のために作っていたものですが、不要になりそうだったので、せっかくなのでアプリ部分だけ公開することにしました。感情分析の精度は恥ずかしくなるようなものなので、ネタ程度に使ってみてください(笑)。本当は形態素解析ももう少し高度にできるのですが、無料公開できるものを作ろうとするとこんな感じになりました。

ウェブアプリそのものの(フロントエンド部分の)ソースコードはこちらで公開しています:
https://github.com/dotnsf/webspeechpi


感情分析 API はフロントエンドには含まれていません(公開していない理由はあまりに雑な実装で恥ずかしいため)。こちらの実装の中身に興味ある方がいたら教えてください。




REST API やフロントエンドフレームワークを使ったウェブアプリケーションを作っていると、CORS (Cross-Origin Resource Sharing) に悩まされることが珍しくありません:
cors.001


CORS とはウェブブラウザ(フロントエンド)の JavaScript におけるセキュリティ制約の1つで、AJAX などを使って HTTP リクエストを行う場合、原則的には同一オリジンからのリクエストだけが許可され、異なるオリジンからのリクエストは CORS 制約によって失敗するようになっています。バックエンドからの HTTP リクエストに関しては一切制約はありませんが、フロントエンドからのリクエストに対してのみ適用されるものです。詳しくはこちらもどうぞ:
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS


ただ REST API とフロントエンド・ウェブアプリケーションを作っている人が同一であったり、そういったリクエストが来るとわかっている前提で REST API を用意する場合など、API 提供側からもこの CORS 制約を無視したくなることがあります。そういった場合のために「REST API 側で特定のオリジンからの HTTP リクエストについては許可する」設定も可能です。具体的には許可するオリジン(https://allowed-origin.com)を指定して、
 Access-Control-Allow-Origin: https://allowed-origin.com
という HTTP レスポンスヘッダを返します。リクエスト元ではこのヘッダの内容を自分自身のオリジンと比較して、一致している場合のみ正常処理とみなす(一致していない場合はエラーとする)という処理が行われます。なお「全てのオリジンからのリクエストを許可する」という指定も可能で、その場合は例外的に
 Access-Control-Allow-Origin: *
という HTTP レスポンスヘッダを返すことで実現できるようになっています。

さて、実際にフロントエンド側を開発していると、以下のような問題に直面することがあります:
(1)開発中やテスト中は http://localhost:3000 で動かす
(2)開発終了後は https://xxxxx.com で実運用する
(3)REST API は(1)とも(2)とも異なる環境で稼働している(つまり CORS 設定しないと動かない)

開発中は自分の PC でコーディングするので http://localhost:3000 のようなローカルホストを使った動作確認となり、実運用中は運用のために用意した https://xxxxx.com というインターネット上のホストを使ってアクセスする、というケースです(特別に珍しいケースではないと思います)。開発(テスト)環境でも実運用環境でも REST API を動かす必要があるため、REST API 側ではいずれのケースでも動作するような CORS 設定ができると楽です(http://localhost:3000 からのリクエストを許可する是非はともかく)。しかし CORS の Access-Control-Allow-Origin ヘッダには1つのオリジン(それも * 以外の正規表現とかは使えずに)しか記述できない、という制約があります。つまり「http://localhost:3000 または https://xxxxx.com からのリクエストを許可する」という Access-Control-Allow-Origin の指定はできないのですが、これをうまく回避・実現する方法はないでしょうか?

1つの考えられる方法として「開発中だけは "Access-Control-Allow-Origin: *" を指定」して全てのオリジンからのリクエストを許可する、という方法も考えられます。が、これはあまりに無防備な設定でもあります。

そこで考えたのが以下の方法です。あらかじめ許可するオリジンを配列として用意した上で、リクエスト元が許可オリジン配列の中に含まれていた場合はそのオリジンを Access-Control-Allow-Origin ヘッダに動的に指定して返す、という方法です。Node.js + Express 環境向けに具体的に作るとこんな感じ:
// app.js
var express = require( 'express' ),
    app = express();

app.use( express.Router() );

var settings_cors = 'CORS' in process.env ? process.env.CORS : '';
app.all( '/*', function( req, res, next ){
  if( settings_cors ){
    var origin = req.headers.origin;
    if( origin ){
      var cors = settings_cors.split( " " ).join( "" ).split( "," );

      //. cors = [ "*" ] への対応が必要
      if( cors.indexOf( '*' ) > -1 ){
        res.setHeader( 'Access-Control-Allow-Origin', '*' );
        res.setHeader( 'Vary', 'Origin' );
      }else{
        if( cors.indexOf( origin ) > -1 ){
          res.setHeader( 'Access-Control-Allow-Origin', origin );
          res.setHeader( 'Vary', 'Origin' );
        }
      }
    }
  }
  next();
});

app.get( '/ping', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'PONG' }, null, 2 ) );
  res.end();
});

var port = process.env.PORT || 8080;
app.listen( port );
console.log( "server starting on " + port + " ..." );


起動時の環境変数 CORS として許可オリジンの配列(以下の例では "http://localhost:3000" と "https://xxxxx.herokuapp.com" の2つ)をカンマ区切りで指定します:
(例)$ CORS=http://localhost:3000,https://xxxxx.herokuapp.com node app

このサンプル(8080 番ポートで起動)では GET /ping というリクエストに { status: true, message: 'PONG' } というレスポンスを返す REST API が定義されていますが、リクエスト元のオリジンが http://localhost:3000 か https://xxxxx.herokuapp.com のいずれかの場合は Access-Control-Allow-Origin ヘッダによって CORS の制約を回避して実行できます。

これでテスト環境、ステージング環境、本番環境などで CORS の設定を変えずに運用できる API サーバーが用意できそうです。


上記サンプルソースコードはこちらに用意しました:
https://github.com/dotnsf/multicors


React.js をはじめとする、いわゆる「フロントエンドフレームワーク」の勉強をしながら色んな機能を実装している中で「あれ?これって・・・」と違和感のように感じた実装内容がありました。自分なりに色々調べたり、ベンダーのサポートに問い合わせたりした結果として「どうやらこれは問題ではないらしい」という結論に落ち着きました。

ただ「これは問題点と分類されることではない」という説明を受けている一方で、「本当に大丈夫なんだろうか?」という違和感は今でもまだ少し残っています。内容が内容だけにどこまで詳しく書くべきかも迷っていましたが、「ベンダーが問題ではないと言っているのだからいいだろう」と割り切って書くことにしました。簡単にいうと「フロントエンドフレームワークを使ったアプリケーションでの認証機能の安全性」についてです。

以下では Auth0 を使ったサンプルを紹介していますが、同様の現象が AWS CognitoIBM AppID でも再現することを確認していることをはじめに記載しておきます(Auth0 を例として選んだのは、この件でのやり取りの中で最も闊達な議論が出来、様々な情報をいただくことができたのでその内容を交えて紹介したいためです。悪意はありません)。細かな実装の違いはありますが、IDaaS に分類される ID 管理のマネージドサービスほぼ全てに共通の事象であると考えていただいていいと思います。


【通常の認証実装と、フロントエンドフレームワークを使った場合の実装との違い】
認証の実装手段による違いを説明するために、以下3種類のアプリケーション例を使うことにします:

A ユーザー管理機能ごと自分で実装し、サーバーサイドでユーザー認証を行うパターン

ある意味で最もシンプルなアーキテクチャです。自分たちで用意するデータベース等でユーザー情報(IDやパスワードハッシュ)も管理する形です。ログイン時はアプリケーションのログイン画面から入力された ID とパスワード(のハッシュ)がデータベース内に格納されているものと一致するかどうかを調べ、一致していればログインを許可する、という流れになります:
a_


これだけなら非常にシンプルとも言えますが、オンラインサインアップやパスワード変更、パスワード忘れといった機能も自分達で用意する必要があったり、既存の他のユーザー情報(社内ユーザーDBなど)との連携時なども自分たちで機能を実装する必要があります。そこまで意識すると必ずしもシンプルな開発が実現できる、とはいえなくなります。


B ユーザー管理機能にマネージドサービス(今回の例では Auth0)を使い、サーバーサイドでユーザー認証を行うパターン

ユーザー管理にマネージドサービスを使うパターンです。Auth0 や AWS Cognito、IBM AppID などが代表的であると思っています。多くの場合で OAuth 2.0 の仕組みが用意されていて、ログイン時のみマネージドサービスのサイトへ飛んでログインし、正しい ID &パスワード情報が入力された場合のみコールバック関数と呼ばれる事前に登録された URL が呼び出される形でアプリケーションの認証が実現できるものです:
b_


上述のAで問題になる、オンラインサインアップやパスワード変更といった面倒な手間も予め用意されているので、ユーザー管理機能まで含めた形で比較的簡単に実装ができるものです。また多くの場合でプログラミング言語ごとの SDK が提供されており、比較的簡単にログイン機能を実装することができます。


C ユーザー認証機能にマネージドサービス(今回の例では Auth0)を使い、クライアントサイドでユーザー認証を行うパターン

Bの仕組みをフロントエンドフレームワーク内で実現するものです。詳しくは後述しますが、フロントエンドフレームワークではコールバック機能が使えないため、PKCE と呼ばれるプロトコルを実装した専用の JavaScript SDK が提供されていることが多いです。今回、自分が問題が発生するのではないかと思ったのはこのパターンです:
c_



ではA、B、Cそれぞれのケースごとに実装やアーキテクチャの違いをセキュリティ面を意識しながら改めて説明します。

まずAのケース。このケースでは利用者が入力した ID とパスワード(ハッシュ)の組み合わせがデータベースのユーザーテーブル内に存在するかどうかを調べることになります。ログイン処理自体はバックエンド側で実装されるケースが多く、フロントエンド側から ID とパスワードを受け取ってパスワードをハッシュ化し、データベースのユーザーテーブル内で同じ ID とパスワードハッシュを持つレコードが存在するかどうかを調べます。存在していた場合は正しい ID とパスワードが入力されたことになるためログインを許可します。この ID とパスワードを受け取った後の一連の処理はバックエンド側で実装される形になるため、利用者からは何をしているのか、どのホストで動いているデータベースにどのような認証情報やプロトコルで接続して、どのような SQL を実行して情報が正しいかどうかを調べたか、といった裏側の情報は一切見ることができません。

一方、悪意を持った利用者がログイン画面から当てずっぽうに ID やパスワードを何度も試す、といった攻撃行為を行った場合でも、そのような記録を(どの IP アドレスから利用しているか、も含めて)アプリケーション・サーバー側のログに残すことができます。したがって「どこまでセキュリティを意識して実装するかにもよるが、比較的安全な実装ができる」と考えることができます。


次にBのケースです。こちらは OAuth 2.0 のコールバックという仕組みを使って実装する想定で説明します。利用者がログインする時に、一時的にプロバイダー(例えば Auth0)が用意するログイン画面に行きます。ここでプロバイダーに登録されたユーザーの ID とパスワード等を入力することが出来た場合にアクセストークンを含めた形で元のアプリケーションの画面に戻ります。アプリケーションではアクセストークンがあるので各種 API を実行できるようになり、ユーザーの詳細情報を取得することもできるようになります。

つまりログイン時に入力される情報は(プロバイダーの画面で入力されるため)アプリケーションからも知ることはできません。またこのログイン画面にリダイレクトする機能や、ログイン成功後のコールバックを受け取る機能については、バックエンド側で ClientID や ClientSecret 、コールバック URL を正しく設定することで可能になります。つまりアプリケーション側でもこの認証部分についてはバックエンド側で処理されることになるため、Aのケースと同様に利用者からは裏側の情報は一切見ることができません

悪意を持った利用者が何度も当てずっぽうにログインを試みる攻撃(ブルートフォースアタック)については、プロバイダー側で「どの IP アドレスから頻繁にログインを試みているか」を特定することができるため、例えばその IP アドレスからのリクエストを遮断する、といった対処が可能になります。


最後のCのケースはどうでしょう?こちらはフロントエンドフレームワークを使っているため、バックエンド側で処理を行うことはできなくなります。そこで PKCE という仕組みを使うのですが、この仕組は充分に安全なのでしょうか? PKCE は悪意のあるアプリケーションからアクセストークンを横取りされないようにするための仕組みでもあるのですが、ではアクセストークンを横取りされなければ大丈夫なのか?という観点で考えてみました。

このCのケースで自分が気になったのは「認証は(アプリケーションから見て外部にある)認証サーバーで」行いつつ、「アプリケーション側にはバックエンドの仕組みがない」という点だと思っています。したがって認証サーバーで認証を行う際に必要な client_id などの情報を安全なバックエンドに持たせることができない、ということになります。

ではどうしているか、というと、実はこれらの情報は全く隠されていません。ビルド時に JavaScript の難読化処理は行われていますが、HTTP でアクセス可能な JavaScript 内に平文で保存されています。この点がフロントエンドフレームワークを使った場合の実装が他のケースと大きく異なる点といえます。


【フロントエンドフレームワークを使った場合に漏れる情報を確認】
実際にこれらの情報が平文で格納されていることを確認してみます。例として実証用に用意したこのサイトを使います:


まず上述のサイトはこちらのサンプルをベースに作っています。このサンプルからはアプリケーション画面の背景色を変えた程度のものを公開しています:


また上述のサイトは Auth0 と、Auth0 が提供するフロントエンドフレームワーク用の SDK を使って実装しています。Auth0 側の設定ではログイン/ログアウトの許可 URL およびコールバック URL は全て同じで、上述の URL そのものを指定しています(これらは本来管理者以外からは見えていないはずのものです)。localhost など、開発/テスト時に指定しがちなもの(=推測される可能性の高いもの)は今回使っていません:
2022050400


このアプリケーションサイトの URL にアクセスすると、以下のようなページが表示されます(元々のサンプルの背景は黒でしたが、後述の比較のため青に変更しています):
2022050401


"LOGIN" と書かれたボタンをクリックすると Auth0 の認証ダイアログが表示され、ここで認証できます。今回はログインしませんが、ログインに成功すると元の画面の "LOGIN" ボタンが "LOGOUT" に変わって、ログアウトできるようになります(ここはサンプルのまま)。今回はそのままブラウザの「戻る」機能で1つ前のページに戻ります:
2022050402


元の画面に戻りました。では改めて公開されている情報をブラウザから確認してみます。画面を右クリックして「ページのソースを表示」を選択します:
2022050403


React によってビルドされた index.html の内容が表示されます。1行で表示されているため少し見にくいのですが、"<script" を検索すると中程にこのような JavaScript をロードしている箇所が見つかります。この JavaScript 部分をクリックして内容を確認します:
2022050404


React と Auth0 SDK によってビルドされた JavaScript ファイルの内容が表示されます。難読化処理が施されているためこれも見にくくなっていますが、この画面で "clientId" という文字列を検索してみます:
2022050405


"clientId" は数か所見つかりますが、その最後の1つの周辺が以下のようになっています:
2022050406

※これ、本当に公開しちゃっていいのか、少し怖いんですけど、後述のように規格としては機密情報ではないらしいので、それを信じて表示しちゃってます。


上図のように "clientId" と "domain" の情報が平文で格納されていることがわかります(わざと clientId が全て見えない位置でスクリーンショットを撮っていますが、実際に試すと全部表示されるはずです)。繰り返しますが、この画面は管理者や開発者だけが見ることのできる画面ではなく、利用者が誰でも見ることのできる画面です。A や B とは異なり、C のケースではこれらの情報をバックエンド側に安全に隠すことができないためです。

ここでは Auth0 を使っている場合の結果を紹介していますが、AWS Cognito や IBM AppID を使った場合でも同様の手順で JavaScript ファイルから設定に使った情報が見えてしまうことを確認しています。


【フロントエンドフレームワークを使った場合に漏れた情報で別のアプリケーションを稼働させる】
さて、上述したようにフロントエンドフレームワークではビルドされた JavaScript から一部の設定内容が見えてしまうことがわかりました。問題はこの状況は安全なのか? です。

ベンダー側の説明をまとめると「これはこのような仕様であり、表示されている情報は機密情報ではない」との認識でした。OAuth 2.0 を定義する RFC6749 でもクライアントを識別する ClientID 自体は単独では機密情報ではない、と明文化されており、これが画面を通じて表示されていることが即イコール危険にさらされている、という認識にはならないと判断しているようでした:
(RFC6749 2.2 より抜粋)The authorization server issues the registered client a client identifier -- a unique string representing the registration information provided by the client. The client identifier is not a secret; it is exposed to the resource owner and MUST NOT be used alone for client authentication

まあ確かにアクセストークンが奪取されるわけではないし、これがわかってもユーザー権限でログインできるわけではない(後述のような環境を作ることができるため、誰にも知られずに好きなだけログインを試すことができる、というだけ)のでギリギリセーフ、といった認識なのだと思います。

とはいえ、それでも全く安心してよいとは思えない点もあります。例えば Auth0 の場合、このフロントエンドフレームワークを使ってアプリケーションを作るために必要な情報は ClientID と Domain 情報、そしてコールバック URL の3つです。前者2つは上述のようにブラウザから確認することができ、また SPA であるためコールバック URL はブラウザのアドレス欄に表示されている文字列(今回の例だと https://dotnsf-auth0.herokuapp.com/)と一致しているはずです。つまり同じ認証機能を使うアプリケーションを作るために必要な3つの情報が全て開示されてしまっていることになります。後は SSL のオレオレ証明書を作って /etc/hosts ファイルを編集すれば、全く同じ Auth0 のサービスを使う、別の(悪意を持った)アプリケーションを作り上げてしまうこともできるわけです:
d_


本当にできるか、実際に試してみました。上述の方法で Auth0 の ClientID, Domain, CallbackURL(今回の場合は https://dotnsf-auth0.herokuapp.com/) の3つを取り出し、これら取り出した情報だけで別のサーバーに別途自分で作ったアプリケーションを適当なサーバーにデプロイして、自己証明書を作って /etc/hosts ファイルも編集した上でアクセスしてみました。自己証明書なので「信頼してアクセス」するための手順は必要ですが、自分でアクセスするだけなので問題なくできてしまいます(オリジナルのアプリケーションとの比較のため、背景を青から赤に変えています):
2022050601


全く同じ認証情報を使って作られているため、Auth0 への認証も(本来のものと同じ条件で)動きます。管理者の知らない所でログインを試すこともできますし、アタックプロテクションが有効になっていれば、アタックがされたことを検知させることもできてしまうはずです:
2022050602


繰り返しますが、上述のアプリケーションでは Auth0 を使ってアプリケーションをビルド&デプロイしていますが、同様のことが AWS Cognito や IBM AppID でも再現します。


【フロントエンドフレームワークを使った場合にこれらの情報が漏れても安全なのか?】
さて問題はこれができてしまうことがどれだけ危ないのか/危なくないのか という判断を行うことになると思います。これを危険だという根拠は以下のようになると思っています:
・本来の管理者の知り得ないところで、自分達が使っているのと同じ認証サービスが使われている
・ブルートフォースアタックが行われていても気付けない
・アタックプロテクションなどの機能が有効になっていると、管理側の知り得ない所からのアタックによってサービスが止まってしまう可能性がある


一方で、規格としてはこれは仕様どおりというか想定通りというか・・・であって、この時点で危険ではないと判断しているようです。以下のような理由であると思います:
・ユーザーのパスワードなどの情報が直接漏れるわけではない
・特定の攻撃者からのみ攻撃できる(一般ユーザーが知らないうちに攻撃に参加している、とか、一般ユーザーが知らないうちに偽サイトに誘導されている、ということは考えにくい)
・アタックプロテクション機能で攻撃を防げる、または攻撃元を特定できる


※ただし特定の条件下でこの C の構成になっている場合、認証サーバー側からも実際のアプリケーションサーバーからも攻撃元を特定できなくなる可能性があると思っています。その具体的な内容はここに書かない方がいいと思ったので控えます。


繰り返しますが、これは OAuth 2.0 の規格としては仕様どおり/想定どおりの挙動であって、(個人情報漏洩といった)直接の被害が起こる内容ではないと思っています。ベンダーとしては規格の上で機密情報とされていない以上は大丈夫、と判断することになるでしょうから、ここまでの情報を元に自分達が管理するサービスにおいて、この機能を使うか使わないか(許可してもよいかよくないか)、を判断すべきことだと思っています。「管理者の知らない所で同じサービスを都合よく使われる事自体が許されない」と判断するのであれば、上述のCのケースのようなフロントエンドフレームワークを使ってのアプリケーション構築を避けて、バックエンド併用の B のような設計にするべき、という意味です。

なんとなく、スッキリとした判断はならないのですが、規格やサービスを提供する側としては「ギリギリセーフ」と判断しているのだと思っています(事実、これが許されないとフロントエンドフレームワークを使ったアプリケーションからの認証サービス利用はできなくなってしまいます)。実際にフロントエンドフレームワークを使って IDaaS の認証サービスを利用するアプリケーションを開発・運用する(可能性がある)場合は、少なくともここで説明したような情報は外部に漏れる可能性がある、という点を理解しておくべきであると思いました。


IBM Cloud から提供されている AI サービス IBM Watson の中で「音声→テキスト変換」を行う Speech to Text APIにおいて、2022/05/05 時点ではまだベータ版機能として提供されている "Speaker Labels" 機能を使ってみました。その様子をサンプルソースコードと併せて紹介します。

なお以下で紹介している様子および内容は 2022/05/05 時点のベータ版のものです。今後 API の実行方法や出力フォーマット、価格、提供しているソースコード等も含めて変更になる可能性もあることをご了承ください。


【Speech to Text サービスにおける Speaker Labels 機能とは】
一般的な Speech to Text サービスから提供されている機能の多くは「一人が話している前提」がありました。要は一人の人が話しているという前提で、その音声データをテキスト化する、というものでした。

IBM Watson Speech to Text サービスにおける Speaker Labels 機能はこの点を改良して、「複数人が話している可能性を考慮」した上で音声データをテキスト化するものです。なお、この機能は 2022/05/05 時点においてはベータ版として提供されており、英語に加えてスペイン語、ドイツ語、チェコ語、韓国語、そして日本語に対応しています。詳しくはこちらを参照ください:
https://cloud.ibm.com/docs/speech-to-text?topic=speech-to-text-speaker-labels


【サンプルとその使い方を紹介】
この Speaker Labels 機能を使った Node.js のサンプルアプリケーションを作って公開してみました。興味のある方はこちらから git clone するかダウンロードして使ってください:
https://github.com/dotnsf/s2t_betas


ソースコードを展開後の、アプリケーションの使い方を紹介します。まずアプリケーションを動かすためには Node.js v14 以上及び npm が必要なので、未導入の場合は自分のシステムにあったモジュールをインストールしておいてください:
https://nodejs.org/

また IBM Watson の Speech to Text サービスインスタンスの API Key およびサービス URL も必要です。無料のライトプラン※でも構わないので IBM Cloud 内に作成し、接続情報から API Key およびサービス URL (apikey の値と url の値)を取得しておいてください(すぐ後で使います):
2022050601

※無料のライトプランの場合、変換できるのは1か月間で 500 分ぶんのデータまで、という制約があります。


また実際に Speech to Text で変換する音声データファイルが必要です。特に今回は Speaker Labels 機能を使うため、二人以上で会話している際の音声データが必要です。自分で録音したものを使っても構いませんし、どこかでサンプルデータをダウンロードして用意していただいても構いません。以下の例では、こちらから提供されている日本語会話サンプルデータを使わせていただきました:
https://www.3anet.co.jp/np/resrcs/333020/

上述のページから提供されているサンプルデータをダウンロードし、使えそうな mp3 ファイルをソースコードの public/ フォルダ内にコピーしておいてください。とりあえず 007.mp3 というサンプルはいい感じに2名の男女が会話している様子のデータになっているので、以下はこのファイルをソースコード内の public/ フォルダにコピーできているものとして説明を進めることにします:
2022050602


会話の音声サンプルデータが public/ フォルダ以下に用意できたらアプリケーションを起動するための準備を(1回だけ)行います。まずソースコードフォルダ直下にある settings.js ファイルをテキストエディタで開き、取得した Speech to Text サービスの API Key とサービス URL をそれぞれ exports.s2t_apikey と exports.s2t_url の値として入力した上で保存します:
2022050603


そして依存ライブラリをインストールします。ソースコードフォルダ直下において、以下のコマンドを実行します:
$ npm install

これで起動の準備が整いました。最後にアプリケーションを起動します:
$ node app

成功すると 8080 番ポートでアプリケーションが起動します。実際に利用するにはウェブブラウザで http://localhost:8080/ にアクセスします。すると以下のような画面になります:
2022050601


左上にはソースコードの public/ フォルダにコピーした音声会話データのファイル名が一覧で表示されています。ここから 007.mp3 というファイルを選択してください(これが比較的わかりやすくていい感じの結果でした)。そして POST ボタンをクリックして Speech to Text を実行します:
2022050602


実行と同時に指定した音声ファイルの再生も開始します(つまり音が出ます)。並行して音声の解析が非同期に行われ、解析結果が少しずつ表示されていく様子を確認できます(ここまではベータ版の機能を使っていません):
2022050603


あるタイミングから確定した文節のテキスト内容が複数の色に分類されて表示されます。この色の分類が話している人の分類でもあります(下の結果では茶色の文字との文字になっているので、二人で会話している様子だと判断されていることになります):
2022050604


007.mp3 を最後まで解析し終えると以下のようになりました。(識別精度はともかく(苦笑))2つの文節の中で2人の人が会話している様子だった、と識別された様子がわかります:
2022050605


【サンプルソースコード内を紹介】
最後にこのアプリケーションのサンプルソースコードの内容を紹介しながら、どのように API を実行して、どのような結果を取得しているのか、という内容を紹介します。先に言っておくと、この Speaker Labels 機能を使う上で API の実行方法自体は(オプションを ON にする以外は)以前と全く同じです。実行結果に新しい情報が含まれるようになるので、その部分の対応が必要になります。 また該当部分はすべて app.js ファイル内にあるので、このファイルの内容と合わせて紹介します。

まず 27 行目で定義しているオブジェクトが Speech to Text 実行時のパラメータに相当するものです。この中で日本語変換モデル等を指定していますが、32 行目の speakerLabels: true によって、ベータ版機能である speakerLabels を有効に設定しています:
27: var s2t_params = {
28:   objectMode: true,
29:   contentType: 'audio/mp3',
30:   model: settings.s2t_model,
31:   smartFormatting: true,
32:   speakerLabels: true,
33:   inactivityTimeout: -1,
34:   interimResults: true,
35:   timestamps: true,
36:   maxAlternatives: 3
37: };

実際の音声→テキスト変換は 88 行目の processAudioFile() 関数で行っています。特にこの例では音声データファイルを一括変換する方法ではなく、WebSocket を使った非同期変換(少しずつ変換結果を受け取る方法)である recognizeUsingWebSocket() (90 行目)を使っています。そして SpeakerLabels を有効にしている場合、この実行結果(92行目)は2通り想定する必要があります。1つは「音声→テキスト変換結果」、もう1つは「どの部分を誰が話していたか、の判定結果」です(一括の同期変換を使った場合はこれらをまとめて取得できますが、今回は非同期変換を使っているためこれらの結果がバラバラに返ってくる可能性を考慮する必要があります):
90: var s2t_stream = my_s2t.s2t.recognizeUsingWebSocket( s2t_params );
91: fs.createReadStream( filepath ).pipe( s2t_stream );
92: s2t_stream.on( 'data', function( evt ){
        :



まず「音声→テキスト変換結果」が返ってきた場合です。この場合、92 行目の evt オブジェクト(=テキスト変換結果)は以下のような形で返されます:
        {
          result_index: 0,
          results: [
            { 
              final: true,
              alternatives: [
                {  //. 候補1
                  transcript: "音声メッセージが既存のウェブサイトを超えたコミュニケーションを実現",
                  confidence: 0.95,
                  timestamps: [
                    [ "音声", 0.36, 0.84 ],
                    [ "メッセージ", 0.84, 1.35 ],
                    [ "が", 1.35, 1.59 ],
                       :
                    [ "実現", 4.13, 4.7 ]
                  ]
                },
                {  //. 候補2
                  :
                }
              ]
            }
          ]
        }

まず変換結果をある程度の区切りでひとまとめにしています(ある程度の空白期間が生じるまでを1つの節とみなしています)。その区切りの番号が result_index 値です(上の例では 0 になっています)。そしてテキスト変換した結果が results 内に配列形式で格納されています。各配列要素の中に final というキーがあり、これが true の場合は節として変換結果が確定したことを意味します(false の場合は節が確定する前の、変換途中での結果が返されていることを意味します)。そして altervatives 内にその変換結果が可能性の高い順にやはり配列で格納されています。特にこの部分に注目してください:
                {  //. 候補1
                  transcript: "音声メッセージが既存のウェブサイトを超えたコミュニケーションを実現",
                  confidence: 0.95,
                  timestamps: [
                    [ "音声", 0.36, 0.84 ],
                    [ "メッセージ", 0.84, 1.35 ],
                    [ "が", 1.35, 1.59 ],
                       :
                    [ "実現", 4.13, 4.7 ]
                  ]
                },

文章としては「音声メッセージが既存のウェブサイトを超えたコミュニケーションを実現」というテキストに変換されていることに加え、その自信度が 0.95 であること、そして各単語が現れる音声開始からの通算秒数が timestamps という配列変数内に格納されています。この例だと音声スタートから 0.36 秒後から 0.84 秒後までの間に「音声」と話されていて、次に 0.84 秒後から 1.35 秒後までの間に「メッセージ」と話されていて、・・・といったように変換結果が分類されています(ここ、後で使います)。

次に変換結果として返される可能性のもう1つ、「誰がどの部分を話しているか」の結果が返される場合、evt 変数の内容は以下のようになります:
        {
          speaker_labels: [
            { 
              from: 0.36,
              to: 0.84,
              speaker: 0,
              confidence 0.67,
              final: false
            },
            {
              from: 0.84,
              to: 1.35,
              speaker: 0,
              confidence: 0.67,
              final: false
            },
              :
            {
              from: 4.13,
              to: 4.7,
              speaker: 1,
              confidence: 0.67,
              final: false
            }
          ]
        }

speaker_labels というキーが含まれている場合はこちらのケースと判断できます。そしてその中身は上の例であれば以下のような意味です:

・0.36 - 0.84 秒の間は 0 番目の人(自信度 0.67)
・0.84 - 1.35 秒の間は 0 番目の人(自信度 0.67) (この2つは同じ人)
   :
・4.13 - 4.7 秒の間は 1 番目の人(自信度 0.67)  (上とは別の人)

先程のテキスト変換結果の timestamps 値と合わせて、どの(何秒時点の)テキスト部分を何番目の人が話しているか、がわかるように speaker というラベルが付けられています。後はこれらをうまく組み合わせて、例えばテキストの色を分けて表示するようにしたものが提供しているサンプルアプリケーションです:
2022050600


なお、現時点での仕様としては以下のような制約があるようです:
・「2名で」話している前提で判断するよう最適化されている(実際には3名以上と判断される場合もあるが、あくまで2名の会話であることを想定した上で最適化されてラベルが付けられる)。
・speaker_labels の結果にも最終結果であることを示す final キーは存在しているが、final = true とならずに終わるケースが多い(なので、現状ここは無視してもよさそう)。


この辺りはあくまでベータ版での仕様なので、精度含めて今後の変更の可能性もあると思っています。ただ少なくともベータ版の現時点ではこの speaker_labels は無料で(無料のライトプランでも)使える機能のようで、今のうちから色々試してみたいと思いました。複数人の会話音声データから複数人の会話テキストを取り出せるようになると会議の議事録とかにも使えそうで、使い道の幅が大きく広がると期待しています。


このページのトップヘ