Zoomの録画ファイル自動アップロード&自動文字起こしをterraformでできるようにしてみた

はじめに

以前書いた以下の記事の内容をそのままterraformでできるようにしてみました。

gamelinks007.hatenablog.com

gamelinks007.hatenablog.com

またついでにSlackなどに投げれるように文字起こししたファイルを受け取るLambda関数も追加してみました。

実際使う場合の構成図

f:id:gamelinks007:20210529000444p:plain

こんな感じでZoomで録画されたデータをWebhookで受け取り、Lambdaなどでよしなにする感じです。 今回のterraformに落とし込んだところでは実装していませんが、最後のSlackの部分は録画データ内に何かしらのNGワードがある場合にアラートが飛ばす感じですね。

作ったもの

github.com

使い方

まず必要な環境変数をターミナルにexportしてください。

export AWS_ACCESS_KEY_ID=xxxxxxxxxxx
export AWS_SECRET_ACCESS_KEY=xxxxxxxxx
export AWS_REGION=xxxxxxxxx
export TF_VAR_access_key=xxxxxxxxx
export TF_VAR_secret_key=xxxxxxxxx
export TF_VAR_role_arn=xxxxxxxxxx

その後、terraform initで初期化します。

terraform init

初期化が完了したらterraform planで差分を確認します。

terraform plan 

確認時に文法エラーなど無ければ、terraform applyでデプロイできます。

terraform apply

最後に作成したAPI GatewayのURLをZoomのWebhookに渡せばOKです。

Zoomの録画データを自動で文字起こししてみた

はじめに

この記事は先日書いたAWSRubyを使って動画ファイルの自動文字起こしを試してみた時の続きです。

gamelinks007.hatenablog.com

今回は実際にZoomの録画データを自動的に文字起こしできるようにしてみました。

注意事項

とりあえず、手順をまとめただけなのでいくつか設定が抜けている可能性があります。 悪しからずご了承ください。

実際の構成図

f:id:gamelinks007:20210519022519p:plain

Zoomで録画ファイルが作成されたときのWebhookをAPI Gatewayで受け取り、LambdaでS3へとダウンロードしています。 ダウンロードされたタイミングでTranscribeの文字起こしジョブをLambdaで実行し、S3へ文字起こししたデータを保存しています。

やったこと

S3にバケットを作成

まずは、S3に動画データと文字起こししたテキストを保存するためのバケットを作成します。

f:id:gamelinks007:20210523160344p:plain

この時注意が必要なのはリージョンをどこにするかです。 ただし、Amazon Transcribeが使えるリージョンと同じリージョンを設定する必要があります。

ちなみに、Amazon Transcribeが使えるリージョンは以下記事によると東京のほかオハイオオレゴンなどがあるようです。

aws.amazon.com

今回はオハイオを使っていきます。

f:id:gamelinks007:20210523161133p:plain

他に設定する項目もないので、このまま「バケットを作成」をクリックします。

バケット作成後、以下のようにrecordingtextディレクトリを作成します。

f:id:gamelinks007:20210523161453p:plain

ディレクトリの作成が完了したら、S3でするべき作業は完了です。

LambdaでS3に録画データを保存

次に、AWS LambdaでS3に録画データを保存できるようにしていきます。

関数の作成

まずは、Lambdaの関数を以下のように作成します。

f:id:gamelinks007:20210523161618p:plain

「デフォルトの実行ロールの変更」は最初から設定されている「基本的な Lambda アクセス権限で新しいロールを作成」でOKです。

最後に「関数の作成」をクリックしてLambdaの関数を作成します。

RubyでZoomの録画データを保存するコード

関数作成後、Zoomの録画データを保存するためのコードをRubyで書いていきます。

require 'json'
require 'logger'
require 'aws-sdk-s3'
require 'open-uri'
require 'net/http'

def lambda_handler(event:, context:)
    logger = Logger.new($stdout)
    
    logger.info(event)
    
    body = JSON.parse(event["body"])
    
    logger.info(body)
    
    logger.info("Get Zoom Recording file download path")
    
    region = 'us-east-2'
    bucket_name = "zoom-recoding-bucket"
    
    s3_client = Aws::S3::Client.new(region: region)
    
    recording_files = body["payload"]["object"]["recording_files"]
    meeting_id = body["payload"]["object"]["id"]
    
    token = body["download_token"]
    path = recording_files[0]["download_url"]
    
    download_path = "#{path}?access_token=#{token}"

    object_key = "#{meeting_id}.mp4"

    logger.info(download_path)
    logger.info(token)
    
    URI.open(download_path) do |file|
        s3_client.put_object({
            :bucket => bucket_name,
            :key    => "recording/#{object_key}",
            :content_type => "movie/mp4",
            :body   => file.read
        })
    end
end

Zoomの録画データをダウンロードする際には、ダウンロード用のトークンとURLを組み合わせる必要があります。 そのため下記のように新しい文字列を生成しています。

    token = body["download_token"]
    path = recording_files[0]["download_url"]
    
    download_path = "#{path}?access_token=#{token}"

また、Lambdaの/tmpでは最大512MBしかファイルを保存できないので大規模な録画データの対応も考え、URI.openで受け取ったデータをそのままS3へと渡しています。 Zoomで保存されている録画データはMP4形式のファイルとなっていますので、MP4でS3に保存するよう指定しています。

    URI.open(download_path) do |file|
        s3_client.put_object({
            :bucket => bucket_name,
            :key    => "recording/#{object_key}",
            :content_type => "movie/mp4",
            :body   => file.read
        })
    end

最後に、Lambdaで使用するメモリとタイムアウトまでの時間を変更します。

f:id:gamelinks007:20210523162954p:plain

これでLambdaで録画データを保存する処理は完成です。

S3への書き込み権限をロールに追加

AWS IAMの管理画面を開き、Lambdaで関数を作成するときに生成したロールを選択します。

ロールにS3への書き込み権限を渡したいので「ポリシーをアタッチします」をクリックし、S3への書き込み権限を付与します。

f:id:gamelinks007:20210523163647p:plain

今回は動作していることが確認できればいいので、FullAccessを選択しています。 本来であれば、書き込みのみの権限にした方がいいと思います。

追加するポリシーをチェックした後、「ポリシーのアタッチ」をクリックし、権限を付与します。

API GatewayでWebhookを受け取れるようにする

次にAmazon API GatewayでWebhookを受け取る窓口を作成します。

API Gatewayの管理画面に移動し、「APIを作成」をクリックします。 以下のような画面が表示されます。

f:id:gamelinks007:20210523164036p:plain

ここでは、「HTTP API」を使用します。「HTTP API」の枠内にある「構築」をクリックします。

f:id:gamelinks007:20210523164206p:plain

クリック後、上記の画面に遷移するので必要な設定を埋めていきます。

まずは「統合を追加」をクリックし、API Gatewayが使用するバックエンドサービスを選択します。 今回は、先ほど作成したLambda関数を指定しています。

また「API名」も入力する必要があるので任意の名前を入力してください。

諸々の設定を入力後、「確認して作成」をクリックします。

f:id:gamelinks007:20210523164510p:plain

上記の画面に遷移します。 設定内容が正しいか確認したうえで「作成」をクリックします。

これでWebhookを受け取る窓口が作成できました。

ZoomでWebhookの送信先を指定

Zoomの開発者としてアカウントを設定後、https://marketplace.zoom.us/develop/createにアクセスします。

f:id:gamelinks007:20210523165018p:plain

今回はWebhookが使えればいいので「Webhook Only」を選択し、新しくアプリを作成します。

アプリ作成後、以下の画面に遷移しますので赤枠の部分を埋めています。

f:id:gamelinks007:20210523165137p:plain

埋め終わったらページ下部の「Continue」をクリックします。

f:id:gamelinks007:20210523165507p:plain

クリック後、上記の画面に遷移します。

「Event Subscriptions」を有効にし、「Subscription Name」に適当な名前を入力し、「Event notification endpoint URL」には先ほど作成したAPI Gatwayのルートを記入します。 また「Event types」には「All Recording have completed」を選択します。

最後に「Continue」をクリックし、アプリを作成します。

これでZoom側での設定は完了です。

S3にファイルがアップロードされたタイミングで文字起こしジョブを投げる

最後にS3にファイルをアップロードされたタイミングで文字起こしをするジョブをTranscribeへと投げる処理を作成していきます。

Lambdaで関数を作成する

ここでも再度Lambdaで関数を作成します。

関数名は任意の名前を付けてください。ランタイムは先ほどと同じようにRuby 2.7を使用します。

関数作成後、以下のようにジョブを投げるコードを書きましょう。

require 'json'
require 'aws-sdk-transcribeservice'
require 'logger'

def lambda_handler(event:, context:)
    logger = Logger.new($stdout)
    
    logger.info("Hook Event")
    logger.info(event)
    
    record = event["Records"][0]
    s3 = record["s3"]
    region = record["awsRegion"]

    bucket = s3["bucket"]["name"]
    object_key = s3["object"]["key"]
    path = "https://#{bucket}.s3-#{region}.amazonaws.com/#{object_key}"
    job_name = "TranscriptionJobName_#{object_key.sub(/recording\//, "")}"
    output_key = object_key.sub(/recording/, "text").sub(/\.mp4/, "")

    logger.info("S3 info")
    logger.info(bucket)
    logger.info(object_key)
    logger.info(path)

    client = Aws::TranscribeService::Client.new(region: region)
    
    response = client.start_transcription_job({
        transcription_job_name: job_name,
        language_code: "ja-JP",
        media_format: "mp4",
        media: {
            media_file_uri: path
        },
        output_bucket_name: bucket,
        output_key: "#{output_key}.txt"
    })

    logger.info("TranscriptionJob start #{response.transcription_job.transcription_job_name}")
end

あとは、S3の/recordingディレクトリにファイルがアップロードされたタイミングでイベントを実行するように設定します。

f:id:gamelinks007:20210523170754p:plain

「トリガーを追加」をクリックし、以下の画面に移動します。

f:id:gamelinks007:20210523170936p:plain

トリガーとなるイベントとバケット名などを入力していきます。 また、プレフィックスrecording/を指定し、特定のディレクトリだけにアップロードされたイベントをフックしています。

最後に、今回作成した関数のロールに「AmazonTranscribeFullAccess」と「完全に削除」を追加します。 これはS3のファイルを読み取る&Transcribeへジョブを投げる権限を追加する必要があるためです。

おわりに

最終的に文字起こしで終わっていますが、ここからさらに録画データ内にNGワードがあった場合にSlackに通知するということもできそうです。

Ruby/AWS S3/AWS Lambda/Amazon Transcribeで動画の文字起こしをする

はじめに

RubyとS3とLambdaとTranscribeを使ってS3にアップロードされた動画を自動的に文字起こしするフローを作ってみました。 この記事はその時の手順などをまとめたものになります。

何故やったかというと、妻が「中国語の動画の字幕が欲しい」と言っていたのがきっかけ。 良い感じに動画の文字起こしができるものがないかと探してたところ、S3とLambdaとTranscribeで行けそうな感じだったのでやってみた感じです

やったこと

S3にアップロードされた動画をLambdaで受け取る

まずはS3にアップロードされた動画をLambdaで受け取ることができるようにします。

AWS Lambdaで以下のように関数を作成します。

f:id:gamelinks007:20210515172027p:plain

作成する関数名はs3-upload-hookとします。 また「ランタイム」はRuby 2.7、「デフォルトの実行ロールの変更」は「基本的な Lambda アクセス権限で新しいロールを作成」を選択します。

最後に「関数の作成」をクリックしてLambdaの関数を作成します。

関数の作成後、使用するコードを以下のように変更します。

require 'json'
require 'logger'

def lambda_handler(event:, context:)
    logger = Logger.new($stdout)
    
    logger.info("Hook Event")
    logger.info(event)
    
    record = event["Records"][0]
    s3 = record["s3"]
    region = record["awsRegion"]

    bucket = s3["bucket"]["name"]
    object_key = s3["object"]["key"]
    path = "https://#{bucket}.s3-#{region}.amazonaws.com/#{object_key}"
    job_name = "TranscriptionJobName_#{object_key.sub(/movie\//, "")}"
    output_key = object_key.sub(/movie/, "text").sub(/\.mp4/, "")

    logger.info("S3 info")
    logger.info(bucket)
    logger.info(object_key)
    logger.info(path)
end

コードの内容としては、S3から受け取ったバケットなどの情報を最終的に出力しています。

次に、S3でファイルがアップロードされたイベントをフックできるようにします。

f:id:gamelinks007:20210515172523p:plain

バケット」には動画がアップロードされるバケットを指定します。特定のディレクトリを指定したい場合はプレフィックスmovie/のようにディレクトリ名を指定します

最後に「追加」をクリックしてトリガーを追加します。

ここまでの段階で、S3にアップロードされた動画ファイルをLambdaで受け取ることができるようになります。

Transcribeで文字起こしをするジョブを作成

次に、LambdaからTranscribeへと文字起こしをするジョブの処理を追加します。

Lambdaのコードに以下の部分を追加します。

require 'json'
+ require 'aws-sdk-transcribeservice'
require 'logger'

def lambda_handler(event:, context:)
    logger = Logger.new($stdout)
    
    logger.info("Hook Event")
    logger.info(event)
    
    record = event["Records"][0]
    s3 = record["s3"]
    region = record["awsRegion"]

    bucket = s3["bucket"]["name"]
    object_key = s3["object"]["key"]
    path = "https://#{bucket}.s3-#{region}.amazonaws.com/#{object_key}"
    job_name = "TranscriptionJobName_#{object_key.sub(/movie\//, "")}"
    output_key = object_key.sub(/movie/, "text").sub(/\.mp4/, "")

    logger.info("S3 info")
    logger.info(bucket)
    logger.info(object_key)
    logger.info(path)

+    client = Aws::TranscribeService::Client.new(region: region)
    
+    response = client.start_transcription_job({
+        transcription_job_name: job_name,
+        language_code: "ja-JP",
+        media_format: "mp4",
+        media: {
+            media_file_uri: path
+        },
+        output_bucket_name: bucket,
+        output_key: "#{output_key}.txt"
+    })
+
+    logger.info("TranscriptionJob start #{response.transcription_job.transcription_job_name}")
end

client = Aws::TranscribeService::Client.new(region: region)でTranscribeへ投げるジョブを作成するためのクライアントを生成します。

次に、以下の部分でTranscribeへ投げるジョブを生成しています。

    response = client.start_transcription_job({
        transcription_job_name: job_name,
        language_code: "ja-JP",
        media_format: "mp4",
        media: {
            media_file_uri: path
        },
        output_bucket_name: bucket,
        output_key: "#{output_key}.txt"
    })

transcription_job_nameは実行するジョブの名前を指定しています。ジョブの名前は一意でないといけないため、同じ名前のジョブがあるとエラーになるので注意が必要です。

language_codeでは文字起こしする動画ファイルの言語を指定し、media_formatでは変換元のファイルの種類を指定します。

media_file_uriには変換元の動画ファイルのパスを渡しています。また、output_bucket_nameoutput_keyで文字起こししたテキストをS3のどこに保存するかを指定しています。

ここまでの段階で、動画をS3にアップロードすると自動的に文字起こしされたファイルがS3に作成されるようになります。

おわりに

意外と簡単に実装できたので、このフローをベースに海外の動画の文字起こしサービスとか作っても面白そうかなと思いました。

Rubyのしくみ輪読会を完走した話

はじめに

半年くらい前から少人数で「Rubyのしくみ」の輪読会をやっていて、最近完走しました。 これはその輪読会のまとめ記事です。

ことの発端

半年くらい前に「Rubyのしくみ、輪読会とかないと読む機会無いよね」という話が出て、「じゃあ、輪読会やりましょうか」ということで始まりました。

輪読会の内容

会場

会場は、machida.rbのDiscordチャンネルを、輪読会の感想などを書くツールとしてmachida.rbのesaをお借りしました! この場を借りて感謝申し上げます!

基本的な進め方

基本的に週一回のペースで開催していました。 当日の流れは大体下記の通りですね。

  1. 前回の振り返り(初回だけ開催趣旨など)
  2. 輪読
  3. 当日の会の振り返り

輪読の時は、セクションなどで区切った部分を黙読 ⇒ 感想・疑問点などの共有・議論 という流れで行いました。

全体を通して、黙読部分よりも感想・疑問点の共有などに時間をかけている感じですね。 これはCRubyの実装についての疑問点などが結構出ていたからですね(パーサー周りとかローカル変数のEPとか......)

良かった点

疑問点の解消

疑問に感じたところや分からなかったところを直ぐに議論できたのはかなり良かったですね!

特にパーサーやローカル変数の参照の仕方などは難しい部分でもあったので、そこで出てきた疑問点などを素早く議論して解消できたのは良かったですね。

継続しやすさ

やはり何人かで同じ書籍を読み進めていくので、継続して同じ本を読み進めやすかったですね。 特に書籍の難度が上がるほど輪読会形式で進める恩恵は大きそうです。

サンプルコードの記載

これは途中からなんですが、Wandboxを使って書籍中のサンプルコードをesaに記載するようにしました。 実行結果とセットで記載していたので、書籍中の説明を理解するのに役立っていましたね。

おわりに

輪読会自体は今回が初参加だったんですが、結構良かったので今後も何かしらの形で輪読会をしていきたいですね。

Rubyのエラーメッセージを日本語に差し替えてみた

はじめに

Rubyの初学者の方から「エラーメッセージの読み方がよくわからない」という話を聞き、「Rubyのエラーメッセージを日本語化できないか」試してみた記事です。

作ったもの

Konnyakuというgemを作りました。

github.com

rubygems.org

gem install konnyakuでインストールできます。

このgemではRubyのエラーメッセージを日本語に変換して出力することができます。 例えば、以下のようなコードがあったとします

puts Hoge # => 未定義の定数 Hogeがputsに渡されている

このコードを実行すると以下のようなエラーが表示されます

uninitialized constant Hoge (NameError)

実行結果: [Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

定義されていない定数Hogeを参照しているのでエラーになっているわけですね 慣れていれば問題はないのですが、初学者の方には英語表記であるため読みにくさを感じるようです

そこで、Konnyaku出番です! 先ほどのコードにrequire "konnyaku"を追加すると以下のようにエラーメッセージが日本語で表示されます

require "konnyaku"

p Hoge
#=> 例外:NameError が発生しました
#=> ソースコード: bug.rb の 3 行目にエラーの原因があります
#=> 定義されていない定数 Hoge があります

Konnyakuを使うことで、こんな感じでエラーメッセージを日本語として表示してくれます

やったこと

TracePointを使って、例外が発生したタイミングでエラーメッセージを日本語に強制的に書き換えることをしています。

class Konnyaku
  class << Konnyaku
    def run
        tp = TracePoint.new(:raise) do |tp|
            lineno = tp.lineno
            path   = tp.path
            msg = tp.raised_exception.message
            puts "例外:#{tp.raised_exception.class} が発生しました"
            puts "ソースコード: #{path}#{lineno.to_s} 行目にエラーの原因があります"
            
            msg = translate(msg) 
            puts msg
        end
        tp.enable
    end
  end
end

エラーメッセージなどの変換はKonnyaku.translateで以下のように無理やり置換しています(このへんもう少しきれいにしたい……)

class Konnyaku
  class << Konnyaku
    def translate(msg)
        case msg
            when /uninitialized constant/
                return msg.gsub(/#{$&}/, "定義されていない定数") + " があります"
            when /undefined local variable or method/
                return msg.gsub(/#{$&}/, "定義されていない変数またはメソッド").gsub(/ for /, "").sub(/`|'/, "").gsub(/for/, "") + " にあります"
            when /undefined method/
                return msg.gsub(/#{$&}/, "定義されていないメソッド").gsub(/ for /, "").gsub(/`|'/, "") + " にあります"
            when /uninitialized class variable/
                return msg.gsub(/#{$&}/, "初期化されていないクラス変数").gsub(/ for.+$/, "").gsub(/ in /, "") + " クラスにあります"
        end 
    end
  end
end

今後

とりあえず、対応していないエラーメッセージとかまだまだあるので追加していこうかと思います。 あと、テスト書けてないのでその辺も進めていきたい……

Refinementsで遊んでみた

はじめに

Refinementsで遊んでみた記事です。具体的な内容としてはトップレベルでRefinementsが使えないかなと試してみたとき備忘録です。

Refinementsとは

Rubyでは、後から自由にクラスにメソッドを追加することができる。

class Integer
  def hoge
    puts :hoge
  end
end

42.hoge
# => :hoge

これはこれで便利なんだけど、グローバルに変更されるし、継承とかでも意図しない動きをする可能性があるのであまりいいとは言えない。

そこでRefinementsの出番。

moduel Refine
   refine Integer do
      def hoge
         p :hoge
      end
   end
end

# 42.hoge
# => Error!

using Refine

42.hoge
# => :hoge

Refinementsではusingした移行で再定義したものを使える。 またusingした箇所のファイルスコープに限定されるので影響範囲が小さくて済む。

やりたかったこと

以下のようにトップレベルでrefineを使って特定のクラスにメソッドを生やしたかった(毎回、usingとか書くのが面倒だったので……)

refine Integer do 
   def hoge
      puts :hoge 
   end
end

42.hoge

ただ、このコードはrefineというメソッドが定義されていないのでエラーになる。

やってみた

で、出来そうか試してみたところ以下のコードで動作した。

module Kernel
    module_function
    def refine klass, &block
        Module.new do
           refine klass do
              yield
           end
        end
    end
end

refine Integer do 
   public
   def hoge
      p :hoge 
   end
end

42.hoge
# => :hoge

やっていること

Refinementsをよく使われる人は以下のようなコードで無名モジュールを使っているんじゃないかと思う。

using Module.new {
    refine Integer do
        def hoge
            p :hoge
        end
    end
}

42.hoge
# => :hoge

このコードを元にKernelモジュールにrefineメソッドを作成。

module Kernel
    module_function
    def refine klass, &block
        Module.new do
           refine klass do
              yield
           end
        end
    end
end

refineメソッドは第一引数に再定義したいクラスなどを受け取り、メソッドの再定義などはブロック引数で渡します。

これにより以下のようにhogeメソッドを追加できます。

refine Integer do 
   public
   def hoge
      puts :hoge 
   end
end

42.hoge
# => :hoge

ただし……

ただ、このコードはusingしていないのに再定義した内容が使用できているんですよね……。

module Kernel
    module_function
    def refine klass, &block
        Module.new do
           refine klass do
              yield
           end
        end
    end
end

# ここで usingを使っていないが作成したhogeメソッドが使えている……
refine Integer do 
   public
   def hoge
      p :hoge 
   end
end

42.hoge
# => :hoge

これはRefinementsの仕様通りの動きなのかがはっきりとしていないのでなぜ動いているのやら……。

それと今回のコードではpublicを追加しないとメソッドが呼び出されなかった。

module Kernel
    module_function
    def refine klass, &block
        Module.new do
           refine klass do
              yield
           end
        end
    end
end

refine Integer do 
   def hoge
      p :hoge 
   end
end

42.hoge
# => private method `hoge' called for 42:Integer (NoMethodError)

エラーからprivateメソッドとして定義されているらしいことがわかったのでpublicを付けてみたら動いたという……。 この辺も仕様通りの動きなんだろうか……?

おわりに

とりあえず、やりたいことはできた。 これが仕様通りの動きなのかは判断がつかないので、Refinementsの実装とか読んでみようかと思う。

参考

qiita.com

secret-garden.hatenablog.com

secret-garden.hatenablog.com

【Ruby 3.0 Advent Calendar 2020】Dataクラスが廃止された【23日目】

はじめに

Ruby 3.0 Advent Calendar 2020 23日目の記事になります。

昨日は、【Ruby 3.0 Advent Calendar 2020】Ruby 3.0 で WEBrick と SDBM が標準ライブラリから削除される【22日目】です。

secret-garden.hatenablog.com

今日は、Ruby3.0でC拡張などで使われていたDataクラスが削除されたことを紹介します。

Dataクラスって?

DataクラスはObjectクラスからnewallocateundefしたクラスで、C拡張を書く際にnewなどが定義されていると困るケースがあったので使用されていました。 そのためRubyを普通に書く上では特に意識することはありません。

docs.ruby-lang.org

廃止されたことによる影響

基本的にRubyのコードレベルでは大きな影響はありません。 あるとすれば、C拡張を使っているgemなどを作っている人は対応が必要になると思います。

とはいえ、こちらのコミットのようにrb_cObjectを使うように変更すればよさそうなので大きな問題はないかと思います

github.com

あと、大きな問題になりそうなケースとしては古くからメンテナンスされていないC拡張のgemなどは3.0から動かなくなると思います。 なので、かなり古いC拡張のgemを使っている場合は移行先を考えるか自作することを検討するといいかもしれませんね。