【Mac 専用】OpenVoice で音声クローン

音声クローン技術の OpenVoice を Mac にインストールして使ってみました。作業を始めてから実際に使えるようになるまで結構大変だったので、やり方をまとめます。また、OpenVoice V2 は日本語ネイティブ対応ということですが、TTS (Text-To-Speech) に使われている MeloTTS の日本語能力がよろしくないので、macOS に標準搭載されている say コマンドで生成した音声を元にクローン音声を生成するスクリプトを書きました。そちらはなかなかの成果が出たので、合わせて紹介します。

OpenVoice (オフィシャル GitHub): https://github.com/myshell-ai/OpenVoice/

Weel さんの紹介記事 (こちらで知りました): https://weel.co.jp/media/tech/openvoice

注意点

OpenVoice v2 は MIT ライセンスで公開されているので、ソースコードも生成された音声も商用利用可能です。ただし、クローン元となる音声を利用する権利を有していない場合は、商用か否かにかかわらず生成された音声を公開する権利はありません。現在の法律がどうであれ、AI の進歩と共に法整備も進んでいくはずです。声やしゃべりで生計を立てている方々もいる以上、許可無く誰かの音声を使用することは何らかの形で罰せられる可能性があります。そのことを理解した上で本記事を読み進めてください。また、ご自身の音声データを公開する場合においても、ライセンスの明記は行うようにしましょう。

免責事項:
このサイトで提供される情報や方法を使用して行われた行為については、私たちは一切の責任を負いません。特に、他人の無断使用または違法な手段によって得られた音声データを使用して音声クローンを作成し、公開することは完全に利用者の自己責任であり、その結果として生じるあらゆる問題やトラブルについても当方は何ら関知せず、一切の責任を負わないものとします。

法律により保護された著作権やパブリシティ権等が侵害される可能性のある行為を行う際には、利用者は事前に適切な法的措置を講じ、自己のリスクにおいて行動するものとし、当方はその結果生じる一切の損害について責任を負わないものとします。

なお、この免責事項は日本国の法律に基づいて解釈されるものとし、適用されるべき法域については当事者の本拠地が所在する国または地域の法律ではなく、日本国の法律を選択的に適用することに同意するものとします。

以上のご理解とご了承をいただきますようお願い申し上げます。

環境

動作確認ができた環境

macOS: 14.5
ffmpeg version 7.0.1
pip version 24.2

環境構築手順

入っていなければ、ffmpeg をインストール (もちろん brew が必要です):

brew update
brew install ffmpeg

ディレクトリを作って OpenVoice リポジトリをクローン。ディレクトリ名はお好きにどうぞ:

mkdir OpenVoice
cd OpenVoice
git clone https://github.com/myshell-ai/OpenVoice.git .

仮想環境を構築して入る。ボクは得意のpipenvですが、これもお好きなのをお使いください。Python は 3.9 必須らしいです:

pipenv --python 3.9
pipenv shell

pipを最新にし、torchchardet、そして OpenVoice をインストール:

python -m pip install --upgrade pip
pip install torch chardet
pip install -e .

OpenVoice V2 をダウンロードして展開:

※ チェックポイントの最新版 (unzip の対象) は、https://github.com/myshell-ai/OpenVoice/blob/main/docs/USAGE.md の Download the checkpoint from here and extract it to the checkpoints_v2 folder. で確認のこと!

wget https://myshell-public-repo-host.s3.amazonaws.com/openvoice/checkpoints_v2_0417.zip
unzip checkpoints_v2_0417.zip
rm checkpoints_v2_0417.zip

以下に従ってファイル openvoice/se_extractor.py を編集 (22行目のcudacpuにし、float16float32に変更):

[[[IMPORTANT]]] modify OpenVoice source for Apple Silicon Mac: https://github.com/reid-prismatic/OpenVoice-Scribe/commit/f681f5bcbc18df3f356953928a78ba6dcff9de99

def split_audio_whisper(audio_path, audio_name, target_dir='processed'):
    global model
    if model is None:
        model = WhisperModel(model_size, device="cpu", compute_type="float32")
    audio = AudioSegment.from_file(audio_path)
    max_len = len(audio)

(ここは必要な場合のみ) OpenVoice V2 オフィシャルでは、音声生成 (TTS、Text-To-Speech) に MeloTTS を使用しているため、別途インストールする手順が示されています。個人的に日本語の能力は不十分だと思いますが、気になる方、オフィシャルの方法でまずは進めたい方は以下も実施してください:

git clone https://github.com/myshell-ai/MeloTTS
cd MeloTTS
pip install -e .
python -m unidic download

(MeloTTS のテストをしたい場合のみ) 以下にテスト用のサンプルコードを貼っておきます (★ MeloTTS テストコード ★をクリックするとコードが開きます)。python japanese.py で実行し、生成された jp.wav をファインダで QuickLook もしくは Terminal からafplay jp.wav で再生できます。テキストの文面は上で紹介した Weel さんの記事の一部ですが、アルファベット表記の “OpenVoice” がガン無視されたり、音引きが部分的にスルーされたり、読点で溜めが無かったり、なんとなく中国語っぽい癖があったり、という感じで、実用するにはきびしいです。ちなみに初回実行時はファイルのダウンロードが発生するので時間がかかります。

★ MeloTTS テストコード ★
from melo.api import TTS
import time

# Speed is adjustable
speed = 1.0
device = 'cpu' # 'mps' にしても動くが、速くはならない

start = time.time()

text = """
OpenVoiceは、正確なトーンカラーのクローニング、柔軟な声のスタイル制御、ゼロショット多言語クローニングを可能にする音声クローニング技術です。

OpenVoiceを使用すると、リファレンス スピーカーの音色を複製するだけでなく、感情、アクセント、リズム、ポーズ、イントネーションなどの音声スタイルをきめ細かく制御できます。

これを使用するとこのような音声が生成できます。
"""
model = TTS(language='JP', device=device)
speaker_ids = model.hps.data.spk2id

output_path = 'jp.wav'
model.tts_to_file(text, speaker_ids['JP'], output_path, speed=speed)

print(f'\nかかった時間: {round(time.time()-start, 2)} 秒\n')

OpenVoice を使う

クローンしたい音声を準備

環境の準備ができたら、クローンしたい音声ファイルを準備します。なるべく高品質で、他の人の声や雑音、BGM 等が入っていない、10秒以上の音声がよさそうです。参考まで、ffmpegで MP4 の映像ファイルから MP3 の音声を書き出すなら、こんな感じで行えます:

ffmpeg -i input.mp4 -vn -acodec libmp3lame myvoice.mp3

OpenVoice/resources フォルダに入れておきます (ファイル名 myvoice.mp3はサンプルコードで使用しているので、そのまま使いましょう):

cp myvoice.mp3 resources

まずはオフィシャルのテストコードで試す (MeloTTS 必須)

オフィシャルのサンプルコード (demo_part3.ipynb GitHub で見るならこちら) を適当にいじって日本語だけ生成するようにし、かかった時間を表示するようにしたものがこちらです。上の手順で MeloTTS をダウンロードした場合はこれでクローンのテストが行えます。OpenVoice ディレクトリ直下においてpython test.pyで実行します:

import os
import torch
from openvoice import se_extractor
from openvoice.api import ToneColorConverter
from melo.api import TTS
import time

home_dir = './'
ckpt_converter = home_dir + 'checkpoints_v2/converter'
device = "cuda:0" if torch.cuda.is_available() else "cpu"
output_dir = 'outputs'

start = time.time()

tone_color_converter = ToneColorConverter(f'{ckpt_converter}/config.json', device=device)
tone_color_converter.load_ckpt(f'{ckpt_converter}/checkpoint.pth')

os.makedirs(output_dir, exist_ok=True)

reference_speaker = home_dir + 'resources/myvoice.mp3' # This is the voice you want to clone
target_se, audio_name = se_extractor.get_se(reference_speaker, tone_color_converter, vad=False)

texts = {
    'JP': "彼は毎朝ジョギングをして体を健康に保っています。"
}

src_path = f'{output_dir}/tmp.wav'

# Speed is adjustable
speed = 1.0

for language, text in texts.items():
    model = TTS(language=language, device=device)
    speaker_ids = model.hps.data.spk2id

    for speaker_key in speaker_ids.keys():
        speaker_id = speaker_ids[speaker_key]
        speaker_key = speaker_key.lower().replace('_', '-')

        source_se = torch.load(f'checkpoints_v2/base_speakers/ses/{speaker_key}.pth', map_location=device)
        model.tts_to_file(text, speaker_id, src_path, speed=speed)
        save_path = f'{output_dir}/output_v2_{speaker_key}.wav'

        # Run the tone color converter
        encode_message = "@MyShell"
        tone_color_converter.convert(
            audio_src_path=src_path,
            src_se=source_se,
            tgt_se=target_se,
            output_path=save_path,
            message=encode_message)
 
print(f'\nかかった時間: {round(time.time()-start, 2)} 秒\n')

生成された音声は outputs に保存されます。afplay outputs/output_v2_jp.wav で再生しましょう。サンプルの文章がちょうど良いのか悪くない感じがするものの、文章をいろいろ変えて試すと結構きびしいことがわかってきます。でもこれって、OpenVoice の問題じゃないんです。

OpenVoice はボイスチェンジャーである

outputs ディレクトリには output_v2_jp.wav 以外に、tmp.wav ファイルがあるのがわかります。実はこれは MeloTTS で作られた、ベースとなる音声ファイルです。OpenVoice はその音声を、ユーザが用意した音声の特徴で置き換える、つまり乱暴に言うとボイスチェンジャー的なことをしています。音楽で言えばピアノで弾いた曲の音色をギターに変えるようなことをしているので、ベースのアクセントを踏襲しますし、他の言語でもしゃべれてしまう、というわけです。

ええぃ、MeloTTS はキツい!Siri さん、お願いします!

我らが macOS にはいにしえ (Mac OS 9?) より音声合成技術が備わっております。Siri さんなんかは iPhone の普及も相まって、なかなかに鍛わっています。面倒なインストール作業も必要なく、ファイルへの書き出しにも対応しています。というわけでボクはあまり使えない MeloTTS を捨て、音声合成部分に macOS 標準搭載のsayコマンドを使用することにしました。簡単なオプションも追加したスクリプトを書いたので紹介します。名付けて clonesay (クローンせい!) です。やかましいですね。

python clonesay.py でとりあえず動きます (-hを付けると、利用できるオプションの一覧を表示します)。macOS で指定されている音声で、テスト用に入れているデフォルトのテキストを音声合成し、myvoice.mp3 の声色にクローンして outputs/output_jp.mp3 として書き出してくれます。実行中にはごちゃごちゃと英文が表示されますが、最終的に「かかった時間」が表示されればおそらく問題無いはずです。

import os
import torch
from openvoice import se_extractor
from openvoice.api import ToneColorConverter
import time
import subprocess
import argparse

# macOS の 'say' コマンドを使用して、ベースとなる音声ファイルを生成
def text_to_speech(text, output_file):
    command = ['say', '-o', output_file, text]
    subprocess.run(command)

# 引数を解析するための設定
parser = argparse.ArgumentParser(description='OpenVoice を利用して音声のクローニングを行います。事前にクローンしたい音声ファイルを resources/myvoice.mp3 \
                                 として保存してから実行してください。macOS の "say" コマンドを利用しています。\
                                 https://github.com/myshell-ai/OpenVoice/')
parser.add_argument('-i', '--input', type=str, required=False, help='しゃべらせたいテキストを直接入力します。例 -i "こんにちは、おげんきですか"')
parser.add_argument('-f', '--file', type=str, required=False, help='しゃべらせたいテキストが入力されたファイルを指定します。同時に指定した場合は -i が優先されます。')
parser.add_argument('-s', '--source', type=str, required=False, help='テキストから音声を生成せずに、指定した既存の音声ファイルを元にクローンを作成します。\
                    同時に指定した場合は -i、-f が優先されます。')
parser.add_argument('-o', '--output', type=str, required=False, help='出力するファイル名を指定します。利用できるフォーマット: mp3(デフォルト), aiff, wav.')
parser.add_argument('-r', '--reference', type=str, required=False, help='クローン元となる音声ファイルを指定します。\
                    初回指定時には必要なデータが processed ディレクトリに作成されます。')
parser.add_argument('-p', '--play', action='store_true', required=False, help='生成された音声を自動再生します。')
args = parser.parse_args()

home_dir = './'
device = 'cpu'
ckpt_converter = home_dir + 'checkpoints_v2/converter'
tone_color_converter = ToneColorConverter(f'{ckpt_converter}/config.json', device=device)
tone_color_converter.load_ckpt(f'{ckpt_converter}/checkpoint.pth')
output_dir = 'outputs'
language = 'jp'
src_path = f'{output_dir}/tmp.aiff'

os.makedirs(output_dir, exist_ok=True)
start = time.time()

# クローン元となる音声ファイルの指定
if args.reference:
    reference_speaker = args.reference
else:
    reference_speaker = home_dir + 'resources/myvoice.mp3' # クローン元となる音声ファイル

target_se, audio_name = se_extractor.get_se(reference_speaker, tone_color_converter, vad=False)

# 発声するテキストを指定もしくは、既存の音声ファイルを指定
if args.input:
    input_text = args.input
#elif os.path.isfile(args.file) and os.path.getsize(args.file) > 0:
elif args.file and os.path.isfile(args.file):
    with open(args.file, 'r', encoding='utf-8') as file:
        input_text = file.read()
elif args.source:
    src_path = args.source
    input_text = ''
else:
    input_text = """
    しゃべらせたい文章を、ダッシュアイで指定してください。その他利用可能なオプションは、ダッシュエイチで確認できます。
    """

# 発声するテキストの元となる音声ファイルを生成
if input_text != '':
    text_to_speech(input_text, src_path)

source_se = torch.load(f'checkpoints_v2/base_speakers/ses/{language}.pth', map_location=device)
save_path = f'{output_dir}/output_{language}.mp3' # デフォルトの出力ファイル

# 出力ファイル名の指定がある場合
if args.output:
    output_extension = os.path.splitext(args.output)[1].lower()
    if output_extension in {'.mp3', '.aiff', '.wav'}:
        save_path = args.output
    else:
        save_path = os.path.splitext(args.output)[0] + '.aiff'
else:
    save_path = f'{output_dir}/output_{language}.mp3'

# OpenVoice processing part
# Run the tone color converter
encode_message = "@MyShell"
tone_color_converter.convert(
    audio_src_path=src_path,
    src_se=source_se,
    tgt_se=target_se,
    output_path=save_path,
    message=encode_message)
 
print(f'\nかかった時間: {round(time.time()-start, 2)} 秒\n')

# Play the generated MP3 file using the default media player
if args.play and os.path.isfile(save_path):
    # Use afplay for macOS, which is a simple command-line audio player
    print(f'ファイル: {save_path}')
    if input_text !="":
        print(input_text)
    subprocess.run(['afplay', save_path])
else:
    print("The output file does not exist.")

簡単にいろいろ試すなら、-iオプションでテキストを入力し、-pで生成後に再生するようにするのが良いと思います。例えばこんな感じ:

python clonesay.py -p -i "本日皆さんに集まっていただいたのは、\
新しいエーアイ活用の方法を議論するのが目的です。どうぞ、よろしくお願いします。"

(“AI” は “エーアイ” にするなど、工夫は必要です)

macOS の音声を変えてみる

多分 macOS デフォルトの音声は Siri (声 2) ですが、さすがに男性の声に寄せるのはきびしいと思います。そのような場合には、システム設定で変更しましょう。コマンドでのやり方はみつかりませんでした (声の管理… から、追加もできるようです)。

システム設定… → アクセシビリティ → 読み上げコンテンツ → システムの声

感想

AI による音声のクローニング手法はいくつかあるようで、以前 GPT-SoVITS というのをいじったときには結構衝撃的な結果でした。数秒の音声からかなり高い再現度だったからです。今回はたまたま Weel さんで見つけた OpenVoice を試したわけですが、正直クローニングのクオリティとしては劣っていると感じています。ベースの音声 (Siri さん) に寄りすぎだな、と。ただ、今後ベースの TTS (Text-To-Speech) の性能がとんでもなく高く、自分がクローンしたい声質に近いものが現れたときには TTS だけ乗り換えれば良いわけで、将来性はあるのかもしれない、とも思っています。また、OpenVoice は TTS に頼らない技術です。なので、ボクのサンプルスクリプトの-sオプションでリアルの人物がしゃべった音声ファイルをソースとして指定すれば、ナチュラルな結果が得られるでしょう。何か使い道はありそうですよね。mac ユーザの方は遊んでみてください。

Image by Stable Diffusion (Mochi Diffusion)

今回の画像は何のひねりも無いプロンプトで描いてもらいました。ステップ数が高ければキレイになったりリアルになったりするわけでも無いんですよね。ランダムでしばらく回し、めぼしい Seed 値が見つかった後は何度か Steps の増減を繰り返し、指と女性の顔が一番ブキミじゃなかった、マシだった、という一枚に決めました。

Date:
2024年9月23日 23:14:52

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
human voice clone technology

Exclude from Image:

Seed:
85304328

Steps:
25

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Flet でビルドしたクライアントサイドウェブアプリをデプロイ

デスクトップ用に書いた Flet アプリのサイズや表示位置などをいじり、同じコード&操作感でウェブアプリとして使えるようにしました。今回のビルド方法はクライアントサイドで動くので (公式で言うところの、Static website) 、WordPress 等のホスティングサーバがあれば公開できるはずです。ついでに Google AdSense の広告も表示させています。

下準備

環境の構築方法などは以前の投稿に書いてあるので今回は省きます。以下の記事を参考に構築してください。

Python のフレームワーク Flet で macOS 用デスクトップアプリをビルド

サーバサイドでデプロイする場合

本記事はクライアントサイドなので、要するに HTML と JavaScript で動く公開方法です。サーバサイド、特に Apache ウェブサーバへデプロイする場合は以下の記事をご覧ください。Nginx ウェブサーバへのデプロイは公式サイトを見てもらうのが良いです。

Flet のウェブアプリを Apache ウェブサーバのリバースプロキシで動かす

ウェブアプリとしてビルドする

以前の macOS アプリビルドの記事の、ビルド実行 (手順 13) 直前まで終わっている事を前提に進めます。ウェブアプリに指定できる項目はあまりないようなので、簡単に以下のコマンドでビルドします。ビルドにかかる時間は、macOS 用にビルドした時と特に変わりないようです (本記事では、上記記事で紹介している fletpassgen を使用しています)。

flet build web

ローカル環境で動作確認

完成したファイル群は、build/web にまとめられています。まずは、ローカルのブラウザで動作することを確認しましょう。以下を実行し、ブラウザで http://localhost:8000 を開きます。一通りいじってみて問題無ければ、いよいよサーバへデプロイします。

python -m http.server --directory build/web
# 終了するときは Ctrl + C
Chrome で開いたところ

サーバにアップロードする前の作業

ディレクトリを指定

今回の例では、https://blog.peddals.com/fletpassgen にアクセスしたときにウェブアプリが開くようにします。そのために、index.html を一箇所書き換えます (ビルドするときにオプション --base-dir "/fletpassgen/" を追加していればこの手順は省けますが、ローカルでテストできません。なので、ビルド後に書き換えましょう)。お使いのエディタで build/web/index.html を開き、以下のように書き換えます。ディレクトリ名前後のスラッシュ (/) は必須です。

  <base href="/fletpassgen/">

フォルダを固める

フォルダ名自体も上記ディレクトリ名に変更し、一つのファイルに固めてアップロードしやすくします。以下実行後、fletpassgen.tar.gz が作られます。

cd build
mv web fletpassgen
tar cvzf fletpassgen.tar.gz fletpassgen

サーバにアップロード&その後の作業

固めたファイルをアップロード

ホスティングサーバへ fletpassgen.tar.gz をアップロードします。Terminal.app から実行する場合は以下のようなコマンドになります。指定するユーザ名、ホスト名、ディレクトリ名は適宜変更してください。

scp fletpassgen.tar.gz username@hostname:~/public_html

圧縮ファイルを展開

その後サーバでアップロードしたファイルを展開します。ssh が使える場合はこんな感じです。最後のコマンドは、不要になった圧縮ファイルを削除しています。

ssh username@hostname
cd ~/public_html
tar xvf fletpassgen.tar.gz
rm fletpassgen.tar.gz

Web ブラウザでアクセス

これまでの作業で、Flet のクライアントサイド (static) ウェブアプリがアップロードできました。実際にアップしたものがこちらからアクセスできます: https://blog.peddals.com/fletpassgen/

最初に数秒アイコンが表示され、その後ローカルでテストしたものと同じウェブアプリが表示されれば成功です。

うまくいかない時は

この方法でビルドした場合、ファイル容量が大きくなります。今回使ったサンプルで、展開後のトータルサイズは 28MB あります。なので、最初にアクセスしたときにはブラウザに必要なファイルを読み込むために多少の時間がかかります。まずはとにかく待ってみましょう。

いくら待ってもアイコンすら表示されない場合は、index.html 内のディレクトリ名の設定、実際に展開したディレクトリ名、ディレクトリやファイルのユーザ・グループ・アクセス権それぞれを見直しましょう。既存のファイルやディレクトリを参考に修正してみてください。

Tips と追加情報

デスクトップアプリと同じコードを使う

こぢんまりとしたデスクトップアプリは、ウィンドウサイズを指定し、リサイズを許可しない事で適当にレイアウトしてもどうにかなったりします (当初 fletwebapp がそうでした)。もちろん作るアプリ次第ですが、ウェブアプリにしたときにデザインが崩れないようにするには、page.window_width= で指定した幅を、ft.Containerwidth= プロパティでも指定するのが良いと思います。

サイズが大きいので注意

上にも書きましたが、こんな簡単なアプリ (Python コード単体で 約 3.9KB) でも、ビルドすると Python 自体や Flet、その他必要なファイルが追加されて約 28MB になってしまいます。デプロイの際には注意が必要です。

一度開けばネットワークが切れても動く

サーバサイドのデプロイではサーバとの接続が切れたときにも動作するように設計する必要がありますが、ブラウザにダウンロードするこの方法では、ネットが切れても動きます。用途によってはこの方法で十分でしょう。

Safari はコピーボタンが動かない

いつか修正されると思いますが、Chrome では動作する Copy ボタンが今回のデプロイ方法では動作しません。コード自体には、Safari だと Copy ボタンを表示しない設定を入れていますが、クライアントサイド (static website) デプロイではブラウザのエージェントを確認することができないためボタンを消すこともできませんでした。サーバサイドでのデプロイであれば、コピーはできないものの、ボタンが消えるハズです (そのうちテストします)。

おまけ: Google AdSense の広告を追加する

こちらのサイトを大いに参考にさせていただきました。ありがとうございます。

Flutterで作ったWebアプリでGoogleAdSenseの広告を表示する。

AdSense の HTML コードを取得

上記サイトを参考にコードを作成し、以下の部分をどこかに保存しておきます。

Google AdSence > 広告 > 広告ユニットごと > ディスプレイ広告 > 名前を付けて、作成

             data-ad-client="xxxxxxxx"
             data-ad-slot="yyyyyyyy"

index.html に style を追加

Flet ウェブアプリのディレクトリ (本記事では fletpassgen) 内にある index.html に以下 CSS の設定を追加します。追加場所は </style> タグのすぐ上あたりが良いでしょう。行番号は、Flet 0.19.0 の場合の参考としてください。

    footer{
        width: 100%;
        height: 100px;
        text-align: center;
        padding: 0;
        position: absolute;
        bottom: 0;
        z-index: 100;
    }

index.html に <footer></footer> ブロックを追加

最終行 </body></html> の上に、以下を追加します。ハイライトした data-ad-clientdata-ad-slot にはそれぞれ、先ほど AdSense からコピーした内容を貼り付けます。行番号に関しては同じく参考まで。

  <footer>
    <style>
    .example_responsive_1 { width: 320px; height: 100px; }
    @media(min-width: 500px) { .example_responsive_1 { width: 468px; height: 60px; } }
    @media(min-width: 800px) { .example_responsive_1 { width: 728px; height: 90px; } } 
    </style>
        <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>
        <!-- Home_Page -->
        <ins class="adsbygoogle"
             style="display:inline-block"
             data-ad-client="AdSence でコピーした内容を貼る"
             data-ad-slot="AdSence でコピーした内容を貼る">
        </ins>
        <script>
    (adsbygoogle = window.adsbygoogle || []).push({});
    </script>
  </footer>

変更内容を保存し、ウェブアプリの画面最下部に広告が表示されていれば成功です。

Image by Stable Diffusion

Date:
2024年1月29日 0:04:44

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
masterpiece, best quality, retro future, successful upload of application

Exclude from Image:

Seed:
3400661084

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

mac でラズパイ起動用 SD カードや USB メモリをまるごとバックアップする簡単な Python スクリプト

ラズパイで使用している SSD が突然読み込めなくなったので、おおよそ1年前にバックアップしてあったディスクイメージを別の SSD にリストアし、諸々1年分を作り直しました。当然すごく面倒だったので、外付けメディアのバックアップを取る極シンプルな mac 用 Python スクリプトを書きました。ターゲットのデバイスを指定すると、バックアップの後イジェクトまでやってくれます。

いちおう、環境

  • macOS: Sonoma 14.2
  • Python: 3.11.6
  • 追加パッケージ等: 不要

普通にやるならこんな手順

mac に記憶デバイスを接続して一覧を表示、ターゲットとなるデバイス番号を確認、デバイスをアンマウントして、バックアップ開始、終了したらイジェクトして、記憶デバイスを物理的に取り外す、というのが一連の手順になります。Ubuntu がインストールされた 128GB SSD をバックアップしたときの例はこんな感じです ($ 以降が入力するコマンド)。

$ diskutil list external
... 省略 ...
/dev/disk4 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *128.0 GB   disk4
   1:             Windows_FAT_32 system-boot             268.4 MB   disk4s1
   2:                      Linux                         127.8 GB   disk4s2
... 省略 ...
$ sudo diskutil unmountDisk /dev/disk4
Password:
Unmount of all volumes on disk4 was successful
$ sudo dd if=/dev/rdisk4 of=RPi3BP_128GB_20231218-1.img bs=6M status=process
127999672320 bytes (128 GB, 119 GiB) copied, 2054 s, 62.3 MB/s
20350+1 records in
20350+1 records out
128035674112 bytes (128 GB, 119 GiB) copied, 2054.98 s, 62.3 MB/s
$ sudo diskutil eject /dev/disk4
Password:
Disk /dev/disk4 ejected

最初のコマンドは接続されている記憶デバイスの一覧を表示するのですが、external を付けて外付けディスクのみ表示させています。複数表示される場合は、表示される SIZE の値を頼りにするか、接続前に一度 diskutil list external を実行し、メディアを接続してから再度 diskutil list external で追加されたメディアの番号を確認します。

dd コマンドの説明:

  • if は、入力ファイルを指定。上の例の /dev/rdisk4 は、シーケンシャルにアクセスされるデバイスを指定しているので、/dev/disk4 とやるより速い
  • of は、出力ファイルを指定。上の例は、コマンドを実行したディレクトリに .img の拡張子を付けて書き出し
  • bs で、一度に処理するブロックサイズを指定。m1 mac mini (16GB RAM) でいくつか値を変えてテストした結果、6M (6メガバイト) が一番安定して速い速度で処理できた
  • status=process を付けると、実行中に進捗状況が表示される

mac 専用 Python スクリプト (とは言ってもほぼシステムコマンドを逐次実行)

import subprocess
import sys
import os
from datetime import datetime

if not os.getuid()==0:
    sys.exit(">> 管理者権限が必要です。'sudo python3 clibackup.py' の形式で再度実行し、パスワードを入力してください。")

subprocess.run(["diskutil", "list", "external"])
dnum = input(">> バックアップする記憶デバイスの番号を数字で入力してください('q' を入力すると終了)。\n>> (例: /dev/disk9 の場合は 9 を入力)\n>> /dev/disk?: ")
if dnum == 'q':
    print('終了しました。')
    sys.exit()
print(f">> ターゲット /dev/disk{dnum} をマウント解除します。")
subprocess.run(["diskutil", "umountDisk", "/dev/rdisk"+dnum])

timestamp = datetime.now().strftime("%Y%m%d%H%M")
#cmd = ["dd", "if=/dev/rdisk"+dnum, f"of=/Volumes/External HDD/SD_Card_Backup/backup_{timestamp}.img", "bs=6M", "status=progress"]
cmd = ["dd", "if=/dev/rdisk"+dnum, f"of=backup_{timestamp}.img", "bs=6M", "status=progress"]

print("\n>> 以下のコマンドを実行してバックアップします:")
for i in range(len(cmd)):
    print(cmd[i], end = " ")
print("\n")

process = subprocess.run(cmd)

print(f"\n>> バックアップが完了しました。デバイス /dev/disk{dnum} をイジェクトします。")
subprocess.run(["diskutil", "eject", "/dev/disk"+dnum])

保存先ディレクトリを指定する場合は、18行目の様に of= の後にパスを入れてください。ダブルクォーテーションでくくってあるので、スペースがあってもエスケープは不要です。作られるディスクイメージのファイル名は、年月日時分を含んだ backup_YYYYmmddHHMM.img になります。実行時の「分」までファイル名に含めているため、既存のファイルを上書きすることは無いでしょう。エラー処理の一切はしていないのであしからず。

使い方

sudo python3 clibackup.py で実行します (sudo を忘れると、メッセージを表示して終了します)。パスワードを入力すると、マウントされている外付け記憶デバイスの一覧が表示されるので、バックアップしたい USB ドライブなり SD カードなりのディスク番号を入力するとバックアップがスタートします。もし対象がわからない場合、一度接続していない状態で本スクリプトを実行し q で終了、その後メディアを接続して再度スクリプトを実行すると、前回は表示されていなかった /dev/disk があるはずなので、その番号を指定します。バックアップが完了すると自動でメディアをイジェクトするので、そのまま mac から取り外してかまいません。

実行例

Raspbian が入った 16GB の micro SD カード (SanDisk Ultra Class 10 A1 MicroSD HC I) を、mac mini の内蔵 SSD にバックアップしたときの実行例です。速度は 34MB/s、大体 8分ほどで完了しています。使用する記憶デバイスや接続の仕方次第で速度は結構違います (本記事頭で使用している SSD は、62.3MB/s)。自分は 2つのパーティションに分けた外付け 4TB HDD があるので、/dev/disk6~8 も表示されていますが、これらも環境によって変わります。

% sudo python clibackup.py 
Password:
/dev/disk4 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *15.9 GB    disk4
   1:             Windows_FAT_16 RECOVERY                1.7 GB     disk4s1
   2:                      Linux                         33.6 MB    disk4s5
   3:             Windows_FAT_32 boot                    72.4 MB    disk4s6
   4:                      Linux                         14.1 GB    disk4s7

/dev/disk6 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *4.0 TB     disk6
   1:                 Apple_APFS Container disk8         2.0 TB     disk6s1
   2:                 Apple_APFS Container disk7         2.0 TB     disk6s2

/dev/disk7 (synthesized):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      APFS Container Scheme -                      +2.0 TB     disk7
                                 Physical Store disk6s2
   1:                APFS Volume External HDD            1.4 TB     disk7s1

/dev/disk8 (synthesized):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      APFS Container Scheme -                      +2.0 TB     disk8
                                 Physical Store disk6s1
   1:                APFS Volume handsome's mac mini...  673.7 GB   disk8s1
   2:                APFS Volume Buffalo HDD             1.0 TB     disk8s2

>> バックアップする記憶デバイスの番号を数字で入力してください('q' を入力すると終了)。
>> (例: /dev/disk9 の場合は 9 を入力)
>> /dev/disk?: 4
>> ターゲット /dev/disk4 をマウント解除します。
Unmount of all volumes on disk4 was successful

>> 以下のコマンドを実行してバックアップします:
dd if=/dev/rdisk4 of=backup_202312231503.img bs=6M status=progress 

  15904800768 bytes (16 GB, 15 GiB) transferred 474.073s, 34 MB/s   
2532+1 records in
2532+1 records out
15931539456 bytes transferred in 474.873141 secs (33549043 bytes/sec)

>> バックアップが完了しました。デバイス /dev/disk4 をイジェクトします。
Disk /dev/disk4 ejected

記憶デバイスへの書き込み

スクリプトを書いていないので、手動でいくつかコマンドを打ってください。手順は本記事最初のサンプルと同様ですが、入出力が逆になります。dd コマンドでは、if= (入力ファイル) にディスクイメージ、of= (出力ファイル) に USB や SD カードなどの /dev/rdiskディスク番号 を指定します。出力先を間違うと取り返しがつかないことになり得るので、注意してください (サンプル通りに実行して大事なデータが消えてしまっても当方では責任とれません)。以下は、/dev/disk4 に SD カードがマウントされているときの例です。メディアを抜いた状態で diskutil list external を一度実行し、メディアをさしてから再度実行すれば、増えた /dev/disk が該当の記憶デバイスになります。

(記憶デバイスを接続していない状態で実行)
$ diskutil list external

(記憶デバイスを接続して再度実行)
$ diskutil list external
... 省略 ...
/dev/disk4 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *15.9 GB    disk4
   1:             Windows_FAT_16 RECOVERY                1.7 GB     disk4s1
   2:                      Linux                         33.6 MB    disk4s5
   3:             Windows_FAT_32 boot                    72.4 MB    disk4s6
   4:                      Linux                         14.1 GB    disk4s7
... 省略 ...
$ sudo diskutil unmountDisk /dev/disk4
Password:
Unmount of all volumes on disk4 was successful
$ sudo dd if=backup_202312231503.img of=/dev/rdisk4 bs=6M status=process

Image by Stable Diffusion

Date:
2023年12月23日 16:42:10

Model:
fruity-mix_split-einsum_compiled

Size:
512 x 512

Include in Image:
comicbook-style, cloned sheep standing side by side

Exclude from Image:

Seed:
3723203146

Steps:
25

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

Flet のウェブアプリを Apache ウェブサーバのリバースプロキシで動かす

Python でサクッとデスクトップアプリが作れる Flet ですが、同じコードに少しの変更をするだけでブラウザで動かせるようになります。今回の記事は、公式サイトにはない、Apache ウェブサーバで Flet アプリを動かす (セルフホスティングする) 方法です。

まず簡単に Flet とは

GUI やウェブフロントエンドの知識・経験がほとんど無くても、お手軽にデスクトップアプリやウェブアプリが作れる Python のフレームワークです。本家 (?) は Flutter という、Google さんが Dart という言語向けに開発しているモバイルアプリ用フレームワークです。それを Python から利用できるようにしたのが Flet と考えて良さそうです。実際、Flet 出書いたコードから吐き出されるエラーをネットで検索すると、Flutter に関するポストが多くヒットします。本記事では Flet も Flutter も深く触れませんので、詳細は他のサイトを漁ってください。

この記事で説明すること

キモは、Apache の TCP ポート指定を利用したリバースプロキシで Flet のウェブアプリを公開する、というところです。公開と言ってもボクの環境では、LAN にある mac からブラウザでアクセスできるようにするまでですが、パブリックに公開している Apache ウェブサーバでも同様の方法で公開できます。Flet の公式サイトでは、こちらの Self Hosting で NGINX ウェブサーバを使用した公開方法が紹介されていますが、それの Apache 版ということです。本記事のタイトルそのものズバリを説明しているサイトが見つからなかったので、まとめました。

環境

  • Ubuntu 20.04 LTS
  • Apache 2.4.41

ざっくりとした手順

  1. Ubuntu サーバに、Flet アプリの実行に必要なパッケージをインストール
  2. Python の仮想環境を作り、Flet をインストール
  3. Flet アプリを準備
  4. リバースプロキシに必要なモジュールを Apache で有効化
  5. Apache のコンフィグファイルを書く
  6. 自動的に起動する設定を書く

細かい手順

Ubuntu サーバに、Flet アプリの実行必要なパッケージをインストール

公式サイトの通り、Linux で Flet を動かすためには GStreamer のインストールが必要です。ここはサクッと入れてしまいましょう。

sudo apt-get update
sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-doc gstreamer1.0-tools gstreamer1.0-x gstreamer1.0-alsa gstreamer1.0-gl gstreamer1.0-gtk3 gstreamer1.0-qt5 gstreamer1.0-pulseaudio

Python の仮想環境を作り、Flet をインストール

ボクは pipenv を使っているので、こんな感じで仮想環境を作ります。手慣れた仮想環境とバージョンで、ここもサクッとどうぞ。サポートされている Python のバージョンは 3.8 以上です。

pipenv --python 3.11
pipenv shell
pip install flet

Flet アプリを準備

とりあえず、公式からそれっぽいものを持ってきましょう。インタラクションが確認できるので、ボクはこちらの Counter app を使用しました。とりあえず counter.py として保存し、最後の行を下のように編集します。

ft.app(target=main, view=None, port=8501)

簡単に説明すると、view=None でウィンドウやブラウザによる表示を行わず、ポート 8501 で待ち受けるよう指示をしています。ポートは同一サーバ上で重複が無ければ何でもかまいません。GUI とブラウザがインストールしてある環境であれば、python3 counter.py で実行すると、http://localhost:8501 にアクセスすればウェブアプリが開くと思います。次の手順以降で、外部にウェブアプリとして公開します。

リバースプロキシに必要なモジュールを Apache で有効化

Apache でリバースプロキシを行うには、いくつか必要なモジュールを追加する必要があります。Flet の場合、web socket も利用するため、wstunnel も必要です。以下は、モジュール追加、Apache の再起動、ステータス確認を実施しています。

sudo a2enmod proxy proxy_http proxy_wstunnel headers
sudo systemctl restart apache2
sudo systemctl status apache2

Apache のコンフィグファイルを書く

この例では、クライアント (PC 等のブラウザ) から flet.dev.peddals.com にアクセスすると Flet ウェブアプリが開く構成にしています。また、別記事に書いた様にこのドメインへの接続は HTTPS 接続になるので、Apache はポート 443 で待ち受け、内部的に 8501 ポートへリバースプロキシしています。このあたりはご自身の環境に合わせて指定してください。

13-14行目の wss:// の部分はひょっとしたら環境によっては必要ないかもしれません。

<VirtualHost *:443>
	ServerName flet.dev.peddals.com

	SSLEngine on
	SSLCertificateFile /etc/letsencrypt/live/dev.peddals.com/fullchain.pem	
	SSLCertificateKeyFile /etc/letsencrypt/live/dev.peddals.com/privkey.pem

	ProxyRequests Off
	ProxyPreserveHost On

	ProxyPass /ws ws://localhost:8501/ws
	ProxyPassReverse /ws ws://localhost:8501/ws
	ProxyPass /ws wss://localhost:8501/ws
	ProxyPassReverse /ws wss://localhost:8501/ws
	ProxyPass / http://localhost:8501/
	ProxyPassReverse / http://localhost:8501/

	ErrorLog ${APACHE_LOG_DIR}/flet.error.log
	CustomLog ${APACHE_LOG_DIR}/flet.log combined

</VirtualHost>

Apache に設定を読み込ませます。

sudo apachectl configtest
sudo systemctl reload apache2
sudo systemctl status apache2

この状態で一度 python3 counter.py で実行し、別のクライアント PC からサイトへアクセスし、動作するか確認してみましょう。wss:// の行を削除して読み込みが終わらない様でしたら追加してください。

自動的に起動する設定を書く

ここは公式のやり方を参考に編集します。自分の環境に合わせたものを貼っておきます。これを fletcounter.service として、counter.py と同じディレクトリに保存しています。

[Unit]
Description=Flet Counter Service
After=network.target

[Service]
User=handsome
Group=handsome
WorkingDirectory=/home/handsome/codes/flet
Environment="PATH=/home/handsome/.local/share/virtualenvs/flet-xuR7EMBP/bin/"
ExecStart=/home/handsome/.local/share/virtualenvs/flet-xuR7EMBP/bin/python3 /home/handsome/codes/flet/counter.py

[Install]
WantedBy=multi-user.target

いじる部分 (いじった内容) は以下の通りです:

  • Description= はご自由に
  • User=Group= には自分のユーザ名 (whoami)
  • WorkingDirectory= には、counter.py のあるディレクトリのパス
  • Environment="PATH= には、python3 のあるディレクトリのパス (which python3 の出力の bin/ まで)
  • ExecStart= の最初の引数は which python3 の出力全て、次の引数には counter.py のフルパス

そして最後にサービスとして起動、有効化します。これも公式のやり方に従います。シンボリックリンクの元ファイルは、上記のファイルを指定します。

cd /etc/systemd/system
sudo ln -s /home/handsome/codes/flet/fletcounter.service
sudo systemctl start fletcounter
sudo systemctl enable fletcounter
sudo systemctl status fletcounter

以上で設定はおしまいです。クライアント PC からアクセスし、カウンターが表示されれば OK です。可能であればサーバを再起動し、起動後にもカウンターが表示されることを確認しましょう。

ハマったところ

自分の環境で当初発生していた、読み込みが一生終わらない状態を解決するのにすごく時間がかかりました。原因は、ProxyPassProxyPassReverse にそれぞれ ws://http:// の両プロトコルだけしか指定していないことでした (公式の NGINX のリバースプロキシのコンフィグにも wss:// は無い) 。wss がウェブソケットのセキュア版 (http に対する https) と気づけなかったら諦めていたと思います。– なんて言いつつ、その後ラズパイの SSD が死に、リバースプロキシの設定をやり直したところ、wss:// の 2行が無くても問題なく動くようになっていました。ナゾ。

Image by Stable Diffusion

Date:
2023年11月25日 23:02:10

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
cartoon, clolorful,
modern ladies working at post office classifying letters

Exclude from Image:

Seed:
4084494267

Steps:
23

Guidance Scale:
11.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

© Peddals.com