8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

IoTLTAdvent Calendar 2022

Day 25

NHKラジオ(らじるらじる)をGoogleCloudPlatformのサービスCloudRunで録音する

Last updated at Posted at 2022-12-24

IoTLTアドベントカレンダー最終日を担当するニアムギです。
普段はねこIoTLTで活動しています。年2回ほど、ねこ参加型のオンラインLTを開催しているのでぜひ登壇or視聴お願いしますー

今回はNHKラジオ(らじるらじる)をGoogleCloudPlatform(以下、GCP)を使って録音する方法について記事にしました。

きっかけ

子どもの習い事で「気象通報を聞いて天気図を書きましょう」という課題が出ました。
tenkizu.png
ラジオで日本近辺の気象情報を聴きながら専用用紙に各地点の風向き・風力・天気・気圧・気温を記載するといったものです。
※低学年にはかなり無理のある課題のため大人が手伝う前提です。

問題点

課題をやるとしても・・・

  • ラジオをリアルタイムに聞いて転記できるとは思えない
  • そもそも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現在のものとなります。(参考)

Type URL
ラジオ第1 https://radio-stream.nhk.jp/hls/live/2023229/nhkradiruakr1/master.m3u8
ラジオ第2 https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8
NHK-FM https://radio-stream.nhk.jp/hls/live/2023507/nhkradiruakfm/master.m3u8

でもラズパイではなくあえて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に保存する
  1. Cron実行(16時開始)
  2. Cloud Runにリクエストを投げる
  3. Cloud Runで録音 + ストレージに保存
    system.png

そもそもCloudRunとは??

CloudRun のトップページより引用
フルマネージドのサーバーレス プラットフォームでお好きな言語(Go、Python、Java、Node.js、.NET)を使用して、スケーラブルなコンテナ型アプリを構築してデプロイできます。

CloudRunとは より引用
Cloud-run-service.png

ざっくり言うと
「クラウド上にコンテナを置いて、簡単なアプリが作れるサービス」
です。
GPIOのないラズパイがクラウドにあるイメージかなと思います。

CloudRunにアプリをデプロイする

方向性が決まったのでコンテナをビルドするを参考に、らじるらじるを録音するコンテナをCloudRunへデプロイします。手順は以下の通りです。

  1. Dockerコンテナを準備
  2. ローカルでコンテナをビルド(ビルド出来るか試す)
  3. CloudBuildでコンテナをビルド
  4. CloudRunへデプロイ
  5. 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というサービスを利用しています。
SecretManager.png

そのためソースは以下のように少し分かりづらい書き口となっています。
詳しくはシークレットの作成とアクセスを参照ください

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}

Container.png

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が生成されます
CloudRun_3.png

CloudSchedulerで定期実行を設定する

CloudRunへ定期的にアクセスするスケジュールを設定します

頻度を"00 16 * * *" (毎日16時)に設定
Scheduler_2.png
時間になったらCloudRunのコンテナのURLにPOSTするように設定
Scheduler_3.png
最終的には以下のようにスケジュールが設定されます
Scheduler_1.png

結果

定期的に録音できました!
※若干タイミングがずれるため、16時ピッタリから録音するのは無理でした。
Storage_1.png

まとめ

  • 「ローカルPCの時と同じように録音できる︖20分も動かせる︖」と思っていましたが、あっさりとデータを取得出来て驚きました。
  • おそらくCloudRunは例えば画像認識やログの整形などちょっとした処理を実行するのに向いているのだと思います。
    (1回のリクエストに20分も動かすのは上手い使い方ではないかと...)

何か参考になれば嬉しいです。ではでは。

参考URL

NHKラジオ(らじるらじる)
NHKネットラジオらじる★らじるをラズベリーパイで自動録音する
CloudFunctions 関数のタイムアウト
CloudRun のトップページ
CloudRunとは
コンテナをビルドする
シークレットの作成とアクセス

8
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?