IoTLTアドベントカレンダー最終日を担当するニアムギです。
普段はねこIoTLTで活動しています。年2回ほど、ねこ参加型のオンラインLTを開催しているのでぜひ登壇or視聴お願いしますー
今回はNHKラジオ(らじるらじる)をGoogleCloudPlatform(以下、GCP)を使って録音する方法について記事にしました。
きっかけ
子どもの習い事で「気象通報を聞いて天気図を書きましょう」という課題が出ました。
ラジオで日本近辺の気象情報を聴きながら専用用紙に各地点の風向き・風力・天気・気圧・気温を記載するといったものです。
※低学年にはかなり無理のある課題のため大人が手伝う前提です。
問題点
課題をやるとしても・・・
- ラジオをリアルタイムに聞いて転記できるとは思えない
- そもそも16時にラジオを聞けない(忘れる)
- 気象通報は聞き逃し番組の対象外のため、あとから聞けるサービスがない
ということで自分で録音する必要が出ました。
仕様はこんな感じです
- 1日1回、16時に定期実行する
- 20分間録音する
- 録音データをストレージに保存する
録音する環境の選定
どのようにすれば録音できるのか検討しました。
ラズパイとPythonで録音できる
調べてみると、ラズパイ+Pythonを使って録音している人たちがいました。
仕組みはシンプルで
「あるURLを叩くとリアルタイムのラジオが流れるので、それを録音する」
というものでした。ラズパイは定期実行(Cron)のために使用しています。
以下のようにffmpegを使えば再生中のらじるらじるを録音できます。
#!/usr/bin/env python
# %%
import ffmpeg
url = 'https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8'
len = 10
saveFile = 'test_weather.mp3'
stream = ffmpeg.input(url, t=len)
stream = ffmpeg.output(stream, saveFile, format='mp3')
ffmpeg.run(stream)
※URLは2022/12/25現在のものとなります。(参考)
でもラズパイではなくあえてGCPを使う
家庭の都合上、ラズパイの使用は不可のため(置いているだけで白い目で見られる)、普段使っているGCPで出来ないか模索しました。一番のネックは20分の録音です。
Pythonを動かす環境の候補
調査した結果は以下の通りです。(ほかの選択肢もあるかもしれません)
コストと労力を考えてCloudRunを選択しました。
サービス | どんなもの? | タイムアウト時間 | コメント |
---|---|---|---|
GoogleCloudEngine | インスタンスを常時動かす | 気にしなくてよい | 録音するだけなのにやり過ぎ&費用も掛かり過ぎ |
GoogleCloudFunctions(第1世代) | リクエストを受けたら動く | 9分 | 録音しきれない |
GoogleCloudFunctions(第2世代)(イベントドリブン) | リクエストを受けたら動く | 9分 | 録音しきれない |
GoogleCloudFunctions(第2世代)(HTTP関数) | リクエストを受けたら動く | 60分 | 調べてみたけど使い方わからず… |
GoogleCloudRun | リクエストを受けたら動く | 60分 | これならいける!!! |
組み合わせたサービス
定期実行して録音したファイルを保存するまでの流れをまとめると以下の通りです。
やりたいこと | サービス |
---|---|
1日1回、16時に定期実行する | CloudSchedulerでCron実行する |
20分間録音する | CloudRunでPythonを動かす |
録音データをストレージに保存する | GoogleCloudStorageに保存する |
そもそもCloudRunとは??
CloudRun のトップページより引用
フルマネージドのサーバーレス プラットフォームでお好きな言語(Go、Python、Java、Node.js、.NET)を使用して、スケーラブルなコンテナ型アプリを構築してデプロイできます。
ざっくり言うと
「クラウド上にコンテナを置いて、簡単なアプリが作れるサービス」
です。
GPIOのないラズパイがクラウドにあるイメージかなと思います。
CloudRunにアプリをデプロイする
方向性が決まったのでコンテナをビルドするを参考に、らじるらじるを録音するコンテナをCloudRunへデプロイします。手順は以下の通りです。
- Dockerコンテナを準備
- ローカルでコンテナをビルド(ビルド出来るか試す)
- CloudBuildでコンテナをビルド
- CloudRunへデプロイ
- CloudSchedulerで定期実行を設定する
Dockerコンテナの準備
以下のファイルを準備します
Dockerfile : Pythonを動かすDocker
requirements.txt : Pythonで使うライブラリ
main.py : リクエストを受けてラジオを録音するプログラム
Dockerfile
Pythonを動かすシンプルな設定です。
ROM python:3.9-slim
# Allow statements and log messages to immediately appear in the Knative logs
ENV PYTHONUNBUFFERED True
# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./
RUN apt-get update && apt-get install -y \
ffmpeg
# Install production dependencies.
RUN pip install --no-cache-dir -r requirements.txt
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app
requirements.txt
Flask==2.1.0
gunicorn==20.1.0
ffmpeg-python==0.2.0
google-cloud-storage==2.1.0
google-cloud-secret-manager==2.12.6
main.py
ログファイルへ出力しているため、つらつらと長くなっています。
またProjectIDなど固有の情報については外部から設定できるようにしています。今回こだわった点です。※後述参照
録音はrecord関数、Storageへのアップロードは _blob.upload_from_filename(TEMP_SAVEPATH + _fileName) のあたりです。
import os
import sys
from datetime import datetime, timezone, timedelta
import time
import ffmpeg
from flask import Flask, render_template
from google.cloud import storage, secretmanager
# Environment Value
PROJECT_ID = os.environ.get("PROJECT_ID")
SECRET_ID = os.environ.get("SECRET_ID")
VERSION = os.environ.get("SECRET_ID_VERSION")
URL = os.environ.get("URL")
RECORD_SECOND = int(os.environ.get("RECORD_SECOND"))
SAVE_FILE_TITLE = os.environ.get("SAVE_FILE_TITLE")
SAVE_FOLDER_BUCKET = os.environ.get("SAVE_FOLDER_BUCKET")
# Global Value
TEMP_SAVEPATH = './'
LOG_FILE = 'recordRadiru.txt'
storage_client = storage.Client()
bucket = None
retMsg = list()
app = Flask(__name__)
def setBucket():
'Connect GoogleCloudStorage Bucket'
client = secretmanager.SecretManagerServiceClient()
path = client.secret_version_path(PROJECT_ID, SECRET_ID, VERSION)
response = client.access_secret_version(name=path)
bucketName = response.payload.data.decode("UTF-8")
global bucket
bucket = storage_client.bucket(bucketName)
def getNowTime():
'NowTime(JST) YYYYMMDD_hhmmss'
JST = timezone(timedelta(hours=+9), 'JST')
_date = datetime.fromtimestamp(time.time(), JST)
return _date.strftime("%Y%m%d_%H%M%S")
def copyLog():
global bucket
blobLogFile = bucket.blob(LOG_FILE)
with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
with blobLogFile.open("r") as f:
logfile.write(f.read())
logfile.write("\n")
def infoMsg(func, msg):
with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
_msg = getNowTime() + '\tINFO\t[' + func + '] ' + msg
logfile.write(_msg + '\n')
global retMsg
retMsg.append(_msg)
def errMsg(func, msg):
with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
_msg = getNowTime() + '\tERROR\t[' + func + '] ' + msg
logfile.write(_msg + '\n')
global retMsg
retMsg.append(_msg)
def record(url, len, saveFile):
_fn = sys._getframe().f_code.co_name
try:
infoMsg(_fn, 'Recoding...')
stream = ffmpeg.input(url, t=len)
stream = ffmpeg.output(stream, saveFile, format='mp3')
ffmpeg.run(stream)
except Exception as e:
tb = sys.exc_info()[2]
errMsg(_fn, '{0}'.format(e.with_traceback(tb)))
infoMsg(_fn, 'end')
def main(isDebug):
# Set Bucket
setBucket()
# Copy Old Log
copyLog()
_fn = 'main.py'
infoMsg(_fn, 'start')
_url = URL
_len = RECORD_SECOND
_saveFileTitle = SAVE_FILE_TITLE
_saveBucketFolder = SAVE_FOLDER_BUCKET + "/"
_saveBucketFolder = _saveBucketFolder.replace("//", "/")
_fileName = getNowTime() + '_'
_fileName += _saveFileTitle
_fileName += '.mp3'
if isDebug:
_len = 10
infoMsg(_fn, 'URL : %s' % _url)
infoMsg(_fn, 'Length : %s' % str(_len))
infoMsg(_fn, 'SaveFile : %s' % _fileName)
record(_url, _len, TEMP_SAVEPATH + _fileName)
# upload
_blob = bucket.blob(_saveBucketFolder + _fileName)
_blob.upload_from_filename(TEMP_SAVEPATH + _fileName)
infoMsg(_fn, 'Save RecordData in Storage : %s' %
_saveBucketFolder + _fileName)
_blob = bucket.blob(LOG_FILE)
_blob.upload_from_filename(TEMP_SAVEPATH + LOG_FILE)
infoMsg(_fn, 'Save LogFile in Storage : %s' % LOG_FILE)
infoMsg(_fn, 'end')
return retMsg
@app.route("/test")
def test():
isDebug = True
ret = main(isDebug)
values = {"message": ret, "isDebug": isDebug}
return render_template('index.html', data=values)
@app.route("/", methods=["POST"])
def index():
isDebug = False
ret = main(isDebug)
values = {"message": ret, "isDebug": isDebug}
return render_template('index.html', data=values)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
StorageのBucketNameを隠す
StorageのBucketNameが誤って外部に漏れないようにするため、SecretManagerというサービスを利用しています。
そのためソースは以下のように少し分かりづらい書き口となっています。
詳しくはシークレットの作成とアクセスを参照ください
def setBucket():
'Connect GoogleCloudStorage Bucket'
client = secretmanager.SecretManagerServiceClient()
path = client.secret_version_path(PROJECT_ID, SECRET_ID, VERSION)
response = client.access_secret_version(name=path)
bucketName = response.payload.data.decode("UTF-8")
global bucket
bucket = storage_client.bucket(bucketName)
ローカルでコンテナをビルド(ビルド出来るか試す)
export DOCKER_TAG=recradio:latest
docker build --tag ${DOCKER_TAG} .
CloudBuildでコンテナをビルド
export GOOGLE_CLOUD_PROJECT=【ProjectIDをセットする】
export DOCKER_TAG=recradio:latest
export IMAGE_URL=gcr.io/${GOOGLE_CLOUD_PROJECT}/${DOCKER_TAG}
gcloud builds submit --tag ${IMAGE_URL}
CloudRunへデプロイ
汎用性を持たせるため、固有の定数はデプロイ時に出来る限り設定しています。
export GOOGLE_CLOUD_PROJECT=【ProjectIDをセットする】
export DOCKER_TAG=recradio:latest
export JOB_NAME=recradio
export IMAGE_URL=gcr.io/${GOOGLE_CLOUD_PROJECT}/${DOCKER_TAG}
export REGION=asia-northeast1
export SECRET_MANAGER_ID=rediruBucket
export SECRET_MANAGER_ID_VERSION=latest
gcloud run deploy ${JOB_NAME} \
--image ${IMAGE_URL} \
--region ${REGION} \
--timeout=30m \
--set-env-vars "PROJECT_ID=${GOOGLE_CLOUD_PROJECT}" \
--set-env-vars "SECRET_ID=${SECRET_MANAGER_ID}" \
--set-env-vars "SECRET_ID_VERSION=${SECRET_MANAGER_ID_VERSION}" \
--set-env-vars "URL=https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8" \
--set-env-vars "RECORD_SECOND=1500" \
--set-env-vars "SAVE_FILE_TITLE=weather" \
--set-env-vars "SAVE_FOLDER_BUCKET=record/"
CloudRunにコンテナがビルドされ、アクセス用のURLが生成されます
CloudSchedulerで定期実行を設定する
CloudRunへ定期的にアクセスするスケジュールを設定します
頻度を"00 16 * * *" (毎日16時)に設定
時間になったらCloudRunのコンテナのURLにPOSTするように設定
最終的には以下のようにスケジュールが設定されます
結果
定期的に録音できました!
※若干タイミングがずれるため、16時ピッタリから録音するのは無理でした。
まとめ
- 「ローカルPCの時と同じように録音できる︖20分も動かせる︖」と思っていましたが、あっさりとデータを取得出来て驚きました。
- おそらくCloudRunは例えば画像認識やログの整形などちょっとした処理を実行するのに向いているのだと思います。
(1回のリクエストに20分も動かすのは上手い使い方ではないかと...)
何か参考になれば嬉しいです。ではでは。
参考URL
NHKラジオ(らじるらじる)
NHKネットラジオらじる★らじるをラズベリーパイで自動録音する
CloudFunctions 関数のタイムアウト
CloudRun のトップページ
CloudRunとは
コンテナをビルドする
シークレットの作成とアクセス