Python で YouTube から動画をダウンロードする

yt-dlp を使えば可能だった。

import sys
from yt_dlp import YoutubeDL

def download_video(video_url):
  ydl_opts = {
    "format": "best",
  }
  with YoutubeDL(ydl_opts) as ydl:
    ydl.download([video_url])

def download_audio(video_url):
  ydl_opts = {
    "format": "m4a/bestaudio/best",
    "postprocessors": [{
      "key": "FFmpegExtractAudio",
      "preferredcodec": "m4a",
    }]
  }
  with YoutubeDL(ydl_opts) as ydl:
    ydl.download([video_url])

if __name__ == "__main__":
  args = sys.argv
  if 2 <= len(args):
    #download_video(args[1])
    download_audio(args[1])
  else:
    print("Arguments are too short. Ex: python download.py <video_url>")

Whisper で音声ファイルを文字起こし

はじめに

感想系のブログ記事は、たまに次のような手順で書くことがある。

  1. iPhone のボイスメモで考えていることをダラダラ話して録音
  2. 文字起こし
  3. 修正してブログ記事にする

ただ、2 の文字起こしが面倒。文字起こしアプリをいくつか試したけど、どれも残念な精度だった。

そんなとき、文字起こし AI の「Whisper」を知った。巷の評判は良さそう。ローカルでも試せるみたい。早速挑戦してみた。

Miniconda インストール

Python と、機械学習で使うパッケージが必要なので、必要最小限インストールできる Miniconda にした。

docs.conda.io

インストールフォルダと、中の Scripts フォルダにはパスを通す。

PyTorch インストール

NVIDIAGPU は持ってないので、CPU 版の PyTorch をインストールする。

conda install pytorch torchvision torchaudio cpuonly -c pytorch

ffmpeg インストール

オーディオファイルを Python から扱うので、ffmpeg をインストールする。

conda install ffmpeg -c conda-forge

Whisper インストール

pip install git+https://github.com/openai/whisper.git

ffmpeg 本体インストール

ffmpegPython のパッケージだけではダメで、本体もインストールする必要があった。

https://ffmpeg.org/download.html

ffmpeg.exe の場所にはパスを通しておく。

Python

コマンドライン引数で指定したファイルを文字起こしするスクリプトを書いてみた。

日本語である程度精度を出したいので、モデルは medium にしてみた。large が最高精度だけど、さすがにメモリが足りなさそうで。

import sys
import whisper

def transcribe(file_path):
  model = whisper.load_model("medium")
  result = model.transcribe(file_path, verbose=True, language="ja")

if __name__ == "__main__":
  args = sys.argv
  if 2 <= len(args):
    transcribe(args[1])
  else:
    print("Arguments are too short. Ex: python test.py <file_path>")

実行結果

「福はこび」の感想をボイスメモに録音したものを文字起こししてみた。

[00:00.000 --> 00:06.000] 福箱広場 名の浜にある福箱広場
[00:06.000 --> 00:09.000] ピンって行ってきました
[00:10.000 --> 00:14.000] 博多には超人気店がある
[00:14.000 --> 00:19.000] 一層の神経大津
[00:19.000 --> 00:22.000] 町から中華
[00:22.000 --> 00:25.000] オープン当初は凄い行列だったらしいんですけど
[00:25.000 --> 00:30.000] 時間も経って大分行列落ち着いたようで
[00:31.000 --> 00:34.000] 夕方でしたけどすぐ入りました
[00:36.000 --> 00:40.000] 今醤油ラーメンとチャーハンのセット
[00:40.000 --> 00:43.000] チェーンを合わせて
[00:43.000 --> 00:46.000] 醤油ラーメンは焦がし醤油の
[00:46.000 --> 00:50.000] 香ばしさとほろみがさ
[00:50.000 --> 00:54.000] 元々の醤油の甘さと
[00:54.000 --> 00:57.000] 甘さみたいに
[00:59.000 --> 01:02.000] よそよりも美味しかったですね
[01:02.000 --> 01:06.000] 黒い醤油ラーメンというのがあると
[01:08.000 --> 01:11.000] 京都の
[01:14.000 --> 01:19.000] ノットフォーニーだったので大分経過してたんですけど
[01:19.000 --> 01:23.000] この黒い醤油ラーメンは
[01:24.000 --> 01:26.000] 美味しくいただけました
[01:26.000 --> 01:28.000] 目も縮んで
[01:28.000 --> 01:32.000] 今スタンダードが縮れ中間にもって感じて
[01:32.000 --> 01:36.000] もうちょっと長かったら良かったんですけどね
[01:36.000 --> 01:39.000] 切れやすいのしない
[01:39.000 --> 01:45.000] チャーハンはすっかりと入れて味をした味ちゃんづつで
[01:45.000 --> 01:47.000] 熱々で
[01:47.000 --> 01:48.000] 美味しかったですね
[01:48.000 --> 01:53.000] 半チャーハンのところが普通のチャーハンで売っていけそうだと
[01:53.000 --> 01:57.000] 結構玉ねぎの甘さが気渡り出たような
[01:58.000 --> 02:00.000] 印象は
[02:00.000 --> 02:02.000] 落ちました
[02:02.000 --> 02:04.000] なかなか
[02:04.000 --> 02:07.000] チャーハンも美味しかったです
[02:08.000 --> 02:10.000] そういった
[02:11.000 --> 02:15.000] ごはんの醤油と塩の二本ばしさにおらんで
[02:15.000 --> 02:19.000] 次に来たら塩を
[02:19.000 --> 02:24.000] 食べてみたいと思いました

固有名詞は怪しいけど、medium ならこんなもんかな。

おわりに

medium でも、まぁまぁの精度だった。それでも、過去に試したどの文字起こしアプリよりも精度は高い。期待通り。いや、期待以上かも。これなら実際に使えるな。

認証に xAuth を使う Web API を Python で呼び出すメモ

requests と標準ライブラリだけを使って書いてみた。 パラメータの指定、特に oauth_signature の作成がシンドイ。 Authorization の指定も。

# -*- coding: utf-8 -*-
from random import getrandbits
from time import time
import hmac
import hashlib 
import urllib
import urlparse
import requests

CONSUMER_KEY = "your consumer_key"
CONSUMER_SECRET = "your consumer_secret"
ACCESS_TOKEN_URL = "access token url"
WEB_API_URL = "web api url"
USERNAME = "your user name"
PASSWORD = "your password"

def escape(s):
    if not isinstance(s, bytes):
        s = s.encode("utf-8")
    return urllib.quote(s, safe="~")

# xAuth に必要なパラメーターを作成
payload = {
    "oauth_consumer_key": CONSUMER_KEY,
    "oauth_signature_method": "HMAC-SHA1",
    "oauth_timestamp": str(int(time())),
    "oauth_nonce": str(getrandbits(64)),
    "oauth_version": "1.0",
    "x_auth_mode": "client_auth",
    "x_auth_username": USERNAME,
    "x_auth_password": PASSWORD,
}
payload["oauth_signature"] = hmac.new(
  "%s&%s" % (CONSUMER_SECRET, ""),
  "&".join([
      "POST",
      escape(ACCESS_TOKEN_URL),
      escape("&".join(["%s=%s" % (escape(x), escape(payload[x]))
          for x in sorted(payload)]))
  ]),
  hashlib.sha1).digest().encode("base64").strip()

# xAuth のアクセストークンを取得
r = requests.post(ACCESS_TOKEN_URL, params=payload)
print r.text
token = urlparse.parse_qs(r.text)
oauth_token = token["oauth_token"][0]
oauth_token_secret = token["oauth_token_secret"][0]

# Web API を呼び出すためのパラメータを作成
payload = {
    "oauth_consumer_key": CONSUMER_KEY,
    "oauth_signature_method": "HMAC-SHA1",
    "oauth_timestamp": str(int(time())),
    "oauth_nonce": str(getrandbits(64)),
    "oauth_version": "1.0",
    "oauth_token": oauth_token,
}
payload["oauth_signature"] = hmac.new(
    "%s&%s" % (CONSUMER_SECRET, oauth_token_secret),
    "&".join([
      "GET",
      escape(WEB_API_URL),
      escape("&".join(["%s=%s" % (escape(x), escape(payload[x]))
          for x in sorted(payload)]))
    ]),
    hashlib.sha1).digest().encode("base64").strip()

# Web API 呼び出し
headers = {
    "Authorization": "OAuth %s" % escape(",".join(
        ['%s="%s"' % (escape(x), escape(payload[x])) for x in payload]))
}
r = requests.get(WEB_API_URL, params=payload, headers=headers)
print r.text

Elasticsearch でバルクインサート

Elasticsearch の Python クライアントを使って、 CloudWatch からダウンロードしたメトリクスの データポイントを 1 件ずつ登録していたけど、さすがに遅い。 遅すぎる。

我慢して使えるレベルではなかったので、 Elasticsearch のバルクインサートを使って高速化を試みた。

# -*- coding: utf-8 -*-
import os
import sys
import json
from elasticsearch import Elasticsearch, helpers

ELASTICSEARCH_URL = "localhost:9200"
METRICS_ROOT_DIR = "/var/log/perform/my-app-name"
INSTANCES = [
    "rds",
]
METRICS = [
    "CPUUtilization",
    "DatabaseConnections",
    "DiskQueueDepth",
    "FreeableMemory",
    "FreeStorageSpace",
    "ReadIOPS",
    "WriteIOPS",
    "ReadLatency",
    "WriteLatency",
    "NetworkReceiveThroughput",
    "NetworkTransmitThroughput",
]


es = Elasticsearch(ELASTICSEARCH_URL)


def post_instance_metrics(dir_path, instance):
    for metric in METRICS:
        file_path = os.path.join(dir_path, metric + ".json")
        with open(file_path) as f:
            data = json.load(f)

            # バルクインサートするデータを作成
            actions = []
            for datapoint in data["Datapoints"]:
                actions.append({
                  "_index": instance,
                  "_type": metric,
                  "_id": datapoint["Timestamp"],
                  "_source": datapoint
                })

            # バルクインサート!!!!!
            helpers.bulk(es, actions)


for instance in INSTANCES:
    metrics_dir = os.path.join(METRICS_ROOT_DIR, instance)
    post_instance_metrics(metrics_dir, instance)

バルクインサートに変えることで、 30 分くらいかかっていたのが 1 分くらいに短縮した。 30 倍速い。 赤い彗星もビックリだ。

動的に Kibana のダッシュボードを作成する

CloudWatch からダウンロードした RDS インスタンスのメトリクスを、 Elasticsearch に突っ込んで、 Kibana のダッシュボードで可視化するところまではできた。

tnakamura.hatenablog.com

RDS インスタンスは今後増えていく予定で、 Kibana のダッシュボードは RDS インスタンスごとに分けたい。 RDS インスタンスが増えるたびに、Kibana をぽちぽち操作してダッシュボードを作るのは手間だな。

なんとか自動化できないものか調べていたら、 Kibana の設定は Elasticsearch の .kibana インデックスに保存されているみたいだった。 ダッシュボードは type = dashboard に、 Visualization は type = visualization に保存されていた。

ということは、ダッシュボードの JSON を .kibana インデックスにスクリプトで登録すれば、 動的にダッシュボードを追加できそうだ。

一から JSON を書くのは面倒なので、すでに作成したダッシュボードや Visualization もろもろの JSON を Kibana からエクスポートし、加工してテンプレートにした。 かなり長いので、一部抜粋。

[
  {
    "_id": "${instance}",
    "_type": "dashboard",
    "_source": {
      "title": "${instance}",
      "hits": 0,
      "description": "",
      "panelsJSON": "[{\"col\":1,\"id\":\"${instance}-CPUUtilization\",\"panelIndex\":1,\"row\":1,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":4,\"id\":\"${instance}-DatabaseConnections\",\"panelIndex\":2,\"row\":1,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":7,\"id\":\"${instance}-FreeableMemory\",\"panelIndex\":3,\"row\":1,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":10,\"id\":\"${instance}-DiskQueueDepth\",\"panelIndex\":4,\"row\":1,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":1,\"id\":\"${instance}-FreeStorageSpace\",\"panelIndex\":5,\"row\":3,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":4,\"id\":\"${instance}-ReadIOPS\",\"panelIndex\":6,\"row\":3,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"col\":7,\"id\":\"${instance}-WriteIOPS\",\"panelIndex\":7,\"row\":3,\"size_x\":3,\"size_y\":2,\"type\":\"visualization\"},{\"id\":\"${instance}-NetworkReceiveThroughput\",\"type\":\"visualization\",\"panelIndex\":8,\"size_x\":3,\"size_y\":2,\"col\":10,\"row\":3},{\"id\":\"${instance}-NetworkTransmitThroughput\",\"type\":\"visualization\",\"panelIndex\":9,\"size_x\":3,\"size_y\":2,\"col\":1,\"row\":5},{\"id\":\"${instance}-ReadLatency\",\"type\":\"visualization\",\"panelIndex\":10,\"size_x\":3,\"size_y\":2,\"col\":4,\"row\":5},{\"id\":\"${instance}-WriteLatency\",\"type\":\"visualization\",\"panelIndex\":11,\"size_x\":3,\"size_y\":2,\"col\":7,\"row\":5}]",
      "optionsJSON": "{\"darkTheme\":true}",
      "uiStateJSON": "{\"P-1\":{\"vis\":{\"legendOpen\":false}},\"P-2\":{\"vis\":{\"legendOpen\":false}},\"P-3\":{\"vis\":{\"legendOpen\":false}},\"P-4\":{\"vis\":{\"legendOpen\":false}},\"P-5\":{\"vis\":{\"legendOpen\":false}},\"P-7\":{\"vis\":{\"legendOpen\":false},\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}},\"P-8\":{\"vis\":{\"legendOpen\":false}},\"P-11\":{\"vis\":{\"legendOpen\":false}},\"P-10\":{\"vis\":{\"legendOpen\":false}},\"P-9\":{\"vis\":{\"legendOpen\":false}},\"P-6\":{\"vis\":{\"legendOpen\":false}}}",
      "version": 1,
      "timeRestore": false,
      "kibanaSavedObjectMeta": {
        "searchSourceJSON": "{\"filter\":[{\"query\":{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"*\"}}}]}"
      }
    }
  },

  // search と visualization は省略
]

このテンプレートからダッシュボードの JSON を出力する Python スクリプトを書いた。

# -*- coding: utf-8 -*-
import os
import sys
import codecs
from string import Template


TEMPLATE_FILE_NAME = "dashboard_template.json"
JSON_FILE_SUFFIX = "_dashboard.json"


class DashboardGenerator(object):

    def _get_work_dir(self):
        dir_path = os.path.abspath(os.path.dirname(__file__))
        return dir_path

    def _get_template_path(self):
        dir_path = self._get_work_dir()
        template_path = os.path.join(dir_path, TEMPLATE_FILE_NAME)
        return template_path

    def _get_template(self):
        template_path = self._get_template_path()
        with open(template_path, "r") as f:
            template_text = f.read()
            template = Template(template_text)
            return template

    def _generate_dashboard_json(self, rds_instance_name):
        template = self._get_template()
        json = template.substitute(instance=rds_instance_name)
        return json

    def _save_dashboard_json(self, rds_instance_name, json):
        file_name = rds_instance_name + JSON_FILE_SUFFIX
        dir_path = self._get_work_dir()
        file_path = os.path.join(dir_path, file_name)
        with codecs.open(file_path, "w", "utf-8") as f:
            f.write(json)

    def generate(self, rds_instance_name):
        json_data = self._generate_dashboard_json(rds_instance_name)
        self._save_dashboard_json(rds_instance_name, json_data)


if __name__ == "__main__":
    generator = DashboardGenerator()
    generator.generate("rds-0001")

出力した JSON を elasticsearch クライアントを使って .kibana インデックスに登録すれば、 ダッシュボードを追加できる。これも Python スクリプトを書いた。

# -*- coding: utf-8 -*-
import os
import sys
import json
from elasticsearch import Elasticsearch


class KibanaSetup(object):
    def __init__(self):
        self.es = Elasticsearch("localhost:9200")

    def _get_work_dir(self):
        dir_path = os.path.abspath(os.path.dirname(__file__))
        return dir_path

    def _get_dashboard_json_path(self, rds_instance_name):
        work_dir = self._get_work_dir()
        file_path = os.path.join(work_dir, rds_instance_name + "_dashboard.json")
        return file_path

    def _load_dashboard_json(self, rds_instance_name):
        file_path = self._get_dashboard_json_path(rds_instance_name)
        with open(file_path) as f:
            json_data = json.load(f)
            return json_data

    def _post_object(self, obj):
        self.es.index(index=".kibana", doc_type=obj["_type"], id=obj["_id"], body=obj["_source"])

    def _create_dashboard(self, rds_instance_name):
        json_data = self._load_dashboard_json(rds_instance_name)
        for obj in json_data:
            self._post_object(obj)

    def setup(self, rds_instance_name):
        self._create_dashboard(rds_instance_name)


if __name__ == "__main__":
    app = KibanaSetup()
    app.setup("rds-0001")

これでやりたいことはだいたい出来た。 ダッシュボードに Visualization を追加するときはテンプレートをいじる必要があって、 そこはまだ手間だけど当面は増やす予定ないからいいことにしよう。

Python の Elasticsearch クライアントを使えばいいことに気付いた

CloudWatch のメトリクスを Elasticsearch に突っ込むスクリプトPython の標準ライブラリだけ使って書いたけど、 Elasticsearch クライアントを使えばよかったことに今さら気付いた。

pip install elasticsearch

でインストールしたら、スクリプトはこんな風に書き直すことができた。

# -*- coding: utf-8 -*-
import os
import sys
import json
from elasticsearch import Elasticsearch

ELASTICSEARCH_URL = "localhost:9200"
METRICS_ROOT_DIR = "/var/log/perform/my-app-name"
INSTANCES = [
    "rds",
]
METRICS = [
    "CPUUtilization",
    "DatabaseConnections",
    "DiskQueueDepth",
    "FreeableMemory",
    "FreeStorageSpace",
    "ReadIOPS",
    "WriteIOPS",
    "ReadLatency",
    "WriteLatency",
    "NetworkReceiveThroughput",
    "NetworkTransmitThroughput",
]


es = Elasticsearch(ELASTICSEARCH_URL)


def post_datapoint(index_name, type_name, datapoint):
    # Elasticsearch クライアントを使ってインデックスにデータを登録
    es.index(index=index_name, doc_type=type_name, id=datapoint["Timestamp"], body=datapoint)


def post_instance_metrics(dir_path, instance):
    for metric in METRICS:
        file_path = os.path.join(dir_path, metric + ".json")
        with open(file_path) as f:
            data = json.load(f)
            for datapoint in data["Datapoints"]:
                post_datapoint(instance, metric, datapoint)


for instance in INSTANCES:
    metrics_dir = os.path.join(METRICS_ROOT_DIR, instance)
    post_instance_metrics(metrics_dir, instance)

スッキリ。 awscli は pip でインストールしたのに、Elasticsearch は標準ライブラリだけ使うというのも、 今考えればおかしな話だ。

さくら VPS 1G の CentOS で Apache + gunicorn のベンチマークをとってみた

はじめに

前回、さくら VPS 1G の CentOSApache と mod_wsgi をインストールしてベンチマークをとってみた。

いろいろな構成でベンチマークをとらないと、インフラ経験ゼロな自分にはどの構成が一番いいのか判断つかないので、次は Apache と gunicorn を組み合わせてみることにした。

gunicorn をインストール

sudo pip install gunicorn

WSGI アプリの作成

gunicorn で動かす Web アプリは、mod_wsgi のとき使ったものを流用する。ただ、gunicorn で動かすには Python パッケージじゃないとダメなので、/var/www に hello ディレクトリを作成し、そこに配置しておく。

sudo mkdir /var/www/hello
cd /var/www/hello
sudo vim hello.py

hello.py は前回と同じ。Hello world を表示するだけ。

def application(environ, start_response):
    status = "200 OK"
    response_headers = [("Content-type", "text/plain")]
    start_response(status, response_headers)
    return ["Hello world!"]

gunicorn の起動

ワーカー数を 60 に指定。デーモンとして実行する。

cd /var/www/hello
sudo gunnicorn -w 60 -D hello:application

ちなみに、デーモンとして実行している gunicorn を終了したいときは

kill <gunicornの親プロセスID>

を実行すればいい。

Apache の設定

/etc/httpd/conf/httpd.conf は前回のものをベースに*1、mod_proxy で gunicorn にリクエストを流すように設定する。

ProxyPass /gunicorn http://localhost:8000

あと、mod_wsgi を読み込まないようにしておく。
設定ファイルを保存したら Apache を再起動。

sudo /etc/init.d/httpd restart

curl で gunicorn にリクエストを流せているか確認する。

curl http://<IP アドレス>/gunicorn

Hello world! が表示されたので、上手くいったみたいだ。

ベンチマークをとってみる

Apache Bench を使って、Apache + gunicorn でもベンチマークをとってみる。gunicorn のバージョンは 0.14.2。

同時接続数50固定でリクエスト数を増やしてテスト
リクエスト数 リクエスト/秒
1000 39.51
2000 41.34
4000 42.54
6000 42.77
8000 43.14
10000 43.08
リクエスト数1000固定で同時接続数を増やしてテスト
同時接続数 リクエスト/秒
10 38.59
50 39.53
100 39.08
150 38.92
200 36.67
300 34.66
400 38.30
ベンチマーク後のメモリ使用量
             total       used       free     shared    buffers     cached
Mem:       1020688     816212     204476          0      35504     132152
-/+ buffers/cache:     648556     372132
Swap:      2096472        788    2095684

まとめ

Apache + gunicorn でベンチマークをとってみたけど、mod_wsgi のときと比べて、結果はほとんど変わらなかった。Apache と gunicorn を同じマシン上で動かしているからで、別マシンだと違ってくるんだろう。

Apache + mod_wsgi はアプリケーションサーバーで、Apache + gunicorn はリバースプロキシ+アプリケーションサーバーなので、比較対象としてはふさわしくない気もするけど。

メモリ使用量は、mod_wsgi に比べて 30MB ほど増えている。ということは、メモリの大部分は Apache が喰っていることになる。Apache + mod_wsgi で 700MB ほど使っていたことを考えると、アプリケーションサーバーは gunicorn の方がメモリがはるかに少なく済みそうだな。

*1:つまり初期設定のまま