flet build macos が、NumPy に対応 (公式バグフィックス)

NumPy を使用した Flet (ver. 0.21.0, 0.21.1) のアプリを macOS 用にビルドすると、アプリの起動後すぐにクラッシュするというバグがありました。そのため、自作アプリ字幕極楽丸では、当初使っていた NumPy による処理を Python ネイティブのみのコードで動くように (Copilot が) 修正してビルドしていました。ずいぶん前に GitHub に Issue を報告していたのですが、本日 Author の Feodor Fitsner から対処法の連絡があり、解決していることが確認できたので共有します。結論を書いちゃうと、たぶんもう一度ビルドするだけで動きます。

解決方法

Flet のバージョン 0.21.20.22.0 (2024/04/19 現在の最新版) のテンプレートファイルをいじって修正されたと言うことです。Flet 自体は変更していないようです。なので、NumPy を組み込んだ Flet アプリを macOS または iOS 用に書いている方は、これらのバージョンの Flet でアプリが動けば大丈夫そうです。

もし古いバージョンを使っている場合は pip install --upgrade flet で最新版にしましょう。バージョン指定でインストールする場合は、こんな感じでできます。

pip install flet==0.21.2

すでにバージョン 0.21.2 以上がインストール済みであれば、単純にビルドし直せばビルド後のアプリはクラッシュしない模様です。字幕極楽丸は大丈夫でした。以下はインストール済み Flet バージョンの確認方法です。

% pip list|grep flet
flet                  0.21.2
flet-core             0.21.2
flet-runtime          0.21.2

他の方法

Flet のコード内に以下を追加することでも解決できるそうです。が、普通にビルドすればテンプレートをダウンロードしてからビルド処理が走るので、0.21.2 以上で動くコードなら NumPy を使っても問題無さそうですね。

import os

os.environ["OPENBLAS_NUM_THREADS"] = "1"

関連した別の issue には理由の詳細が書かれてあるので、興味のある方はご参照ください。

まぁとにかく、ありがたいですね

無償で利用できる OSS (Open Source Software) として Flet を公開してくれているので、Issue を申請してからは催促等はせず完全に待ちのスタンスを取っていたのですが、こうやって修正してくれるのはありがたいですね。Flet を使ったアプリを公開していくことで貢献しようと思いました。

Image by Stable Diffusion

今回の画像は、魔術師が小さなモンスターを倒したところを期待してこういう感じのプロンプトで Mochi Diffusion にお願いしました。投稿ごとの画像のクオリティが違いすぎてヤバいですね。M2 Max になってからは、ステップ 30位で 6つ位描いてもらって、それらを元にプロンプトやモデルを変更しています。

Date:
2024年4月20日 0:14:30

Model:
fruity-mix_split-einsum_compiled

Size:
512 x 512

Include in Image:
fantazy, realistic painting, a wizard with a magic wand killed misterious creature by fireball

Exclude from Image:

Seed:
2031597071

Steps:
50

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Flet を使ったデスクトップアプリ『字幕極楽丸』をどう作ったか解説

Python でかっこよいデスクトップアプリが作れる Flet。以前公開した、音声と字幕 (SRT) の同時再生・字幕編集アプリ『字幕極楽丸』をどのようにして作ったのか、背景や手順、そしてコードの内容を公開します。完成品はスタンドアロンのデスクトップアプリで、複雑なことはしていません。ですが、Python + Flet で一つのアプリケーションを完成させるまでの手順はあまり見かけないので、何かの足しにしてもらえたらと思います。超大作なので、目次や検索を使って必要なところをかいつまむのがお勧めです。

紹介するコードの置き場所

Python のコードや、実行したときに読み込むロゴ、ビルドした時にアイコンになる画像などは全て GitHub に置いてあります。

オフィシャルの情報

初めて Flet に触る方は、オフィシャルのドキュメントをざっくり読んでください。英語です。

新しいリリースがあると、ブログや Discord に情報が掲載されます。その他諸々のリンクは Support ページにまとまっていますので、そちらから飛んでください。

あまり技術的じゃないところ

作者と制作背景

外資系企業日本オフィスの IT マネージャ。プログラムは趣味で、人にお見せできないような小さなもの、未完成品、POC 的なものを数十年作っている (8bit BASIC ~ HyperCard/Talk ~ HTML/JavaScript ~ Python)。Python の入門書は数冊、それぞれ 60-80% くらいまでは読んでいる。途中でやめてしまっているのは、その段階で作りたくなったものを作ることに集中してしまうため。これまで Tkinter や PySimpleGUI といったデスクトップのフレームワークを使ってアプリを作るも、しっくりこず。たまたま見つけた Flet のデザイン性や、(それなりに) 簡単にデスクトップ・ウェブ・スマホそれぞれ向けのアプリが作れるところに惚れ込み、いじり始める。そんなある日、OpenAI の音声認識 Whisper の優秀さに衝撃を受け、衝動的に字幕編集アプリを作り始める (探したけど、それっぽいものが世の中に無かったので)。本アプリの前に一つ、完成品としてパスワード生成アプリを Flet で作っている (web でも動く)。

制作環境

  • Mac (Mac mini M1 16GB RAM から、制作途中で Mac Studio M2 Max 12-core CPU/30-core GPU/32GB RAM の整備済品へ移行)
  • キーボード: HHKB Pro 2 Type-S (有線専用モデル)
  • マウス: Logi の静音マウス
  • モニタ: Dell 4K 27-inch と QHD 24-inch
  • IDE: VisualStudio Code – Insiders (M1 mac mini 使い始めた時からそのまま)
  • バージョン管理: GitHub と GitHub Desktop
  • 画像生成: Mochi Diffusion
  • 音声認識・字幕生成: MLX 版 Whisper と自作 SRT 生成プログラム
  • テスト用ファイル: yt_dlp で m4a を取得、SRT を生成して使用
  • メモ・タスク整理: Smartsheet (無料アカウント)、Apple 標準のメモアプリ
  • 手書き・思考整理: A4判の nu board (ノート型ホワイトボード)、PILOT ボードマスター S
  • 頻繁に参考にするサイト等: Flet 公式サイト、同 Discord、Qiita、Copilot 無料版 (コードを書いてもらう)
  • Python: 3.11.7
  • Flet: 0.21.2 (pip install flet==0.21.2)
  • その他、過去の投稿 (こことかこことか) にもビルドに必要な情報等が書いてあります

どういうプロセスで作っていったか

大体下のような流れでした。考えたとおりにすんなり動くのはまれで、Flet の実装方法が理解できずに、読んでは書いて、動かないので消して、と足踏み状態が数日続くこともありました。それによって完成までのモチベーションは落ちませんでしたが、頭の切り替えをした方がよさそうな時には、Whisper の精度を上げる方法を調べてパラメータを変える実験をしたり、アプリのターゲットユーザやユースケースを想像したりと、やや Flet での開発からは離れてリフレッシュし、また戻る、ということを繰り返しながら作り上げていった感じです。

  1. オフィシャルの auido コントロールを丸パクリして音を鳴らしてみる
  2. ローカルの音声ファイルをコード内で指定して再生させる
  3. インターフェイスのラフをホワイトボード (nu board) に描きながらイメージを作っていく
  4. 音声の再生状況に応じて動くスライダを実装。コンソールには再生時間が毎秒表示されるのにスライダが動かなく悩むが、単純に音声ファイルが長かったのでスライダの異動が微少だったと判明し、安堵
  5. 上とは逆に、スライダを移動させたところから音声が再生される機能を実装。こう書くと一瞬だが、何度もやり直して数日かかった
  6. FilePicker で音声ファイルを読み込む機能を実装。余計なことをしなくても、フォルダを覚えてくれていてスゲーと思う
  7. 音声ファイルを開いたら、同名で拡張子が .srt や .txt の字幕ファイルを読み込む機能を実装
  8. オフィシャル Tutorial の To-Do アプリをほぼパクり、テキストからタイムスタンプと字幕ボタンを生成する機能を実装。ここが動いたときは結構感動
  9. ミリ秒と 00:00:00,000 形式双方の変換処理実装 (ここ含め、ロジック部分には Copilot のアドバイスを積極採用)
  10. メインの処理部分を Class 化。この後徐々にクラスの必要性や意味を理解してくる
  11. 非同期処理 async で動くように全体を書き直し。ボタンが多いときの反応の鈍さを改善したかったのだが効果無し。。。後に Flet が async ファーストとなり、期せずして先取った形に
  12. Class 間での処理のやりとりを実装 (例: 音声の流れに応じて字幕をスクロール、タイムスタンプのボタンを押すと、そこから再生、等)。Class を実地で再勉強
  13. ファイルの上書き保存、書き出し実装。同名ファイルがあるときの警告は OS がやってくれてベンリーと思う
  14. テキストファイルを読み込んだときや存在しないときに通知する SnackBar を実装。簡単に使えてじゃまにならず効果的かつかっこよい。多用したくなるのをがまんする
  15. ダイアログが開かなくなり、アプリを終了するしか無くなるバグ発生。再現性が低く解決困難なため、書き出しダイアログをボタンに変更
  16. アプリとして公開することを考え、フリーのフォントを探してロゴとアイコンを制作。バグに心をやられて寄り道
  17. コピーライト表記追加や全体的なデザインの手直し。バグを既知の問題として公開する方向で腹をくくる
  18. macOS アプリとして書き出すも NumPy が原因でクラッシュするバグに襲われ Discord の help で相談→有力情報無く、GitHub に issue 登録
  19. ウェブアプリに方向転換しようとするも、ローカルのファイルを直接開く事ができず、とりあえず断念
  20. Python から実行する形でとりあえず GitHub に公開、ブログ投稿
  21. Copilot から NumPy を使わない実装を教えてもらい、macOS 用ビルド成功。信頼感爆上げ
  22. GitHub にビルド方法追記、新たにビルド方法のブログ記事投稿
  23. 本記事をやっと書き始める

全体像

完成したアプリ

GUI 全体像イメージ

手描きですみません。ホワイトボードそのものがアプリ (=page) と考えてもらって良いです。その中にある一番大きい column の中身はメインの class で定義しています。一番下の点線 Audio と Dialogs は通常表示されない overlay として class の外にある main 関数でページに追加されています。それら以外は containerrow でまとめたものを上から下に向かってコード内で追加していくイメージです。

コード全体像イメージ

上からこんな感じでコードの中身が分かれています。該当する行番号 (xx-yy) と大まかな内容をまとめました。Github かエディタでコードを開いて見比べてください。

  1. (1-4) Flet その他をインポート — os はパスの操作、datetime はファイル名に日時を付加するためだけなので、アプリに必要な要素・機能はほぼ Flet のみで作られています
  2. (6-79) 関数ブロック — ミリ秒とデジタル表示の変換、読み込んだテキストをアプリ内で使用するリスト形式に変換
  3. (81-183) 字幕を変換したリストからボタンを作る SubButton クラス — 初期化関数、レイアウトをする build 関数、ボタンをクリックしたときの各種処理を行う関数で構成
  4. (185-791) アプリのメインとなる AudioSubPlayer クラス — まず、初期化関数 (187-374) でアプリのレイアウトで使用するボタン (BTN) や、テキスト (TXT)、その他の Flet コントールを self.foobar の形で全て定義し、次のメソッドブロック (376-738) でクリック等のイベントに応じたロジックを async で定義、最後の build 関数 (740-791) でページのレイアウトを定義
  5. (793-812) main 関数 — async でウィンドウ自体の基本を定義し、audio と dialog のインスタンスを overlay としてページに追加
  6. (815) main 関数を呼び出し

自分の書き方の問題で無駄に長い部分もあるとは思いますが、どうやら Flet のコードは長くなりがちなようです。

SRT ファイルについて

本アプリで現在サポートしている字幕ファイルの形式は、SRT となります。中身はテキストのファイルで、拡張子が .srt となります。WikiPedia によると、元々は Windows 用フリーウェアの SubRip で生成されたテキスト字幕ファイル形式、ということです。Whisper で音声を文字に変換 (speech-to-text) する際に使われていたため採用しました。macOS 用 Whisper で音声ファイルを SRT に書き出す方法 (簡単な Python コード) はこちらの記事に書きました。

SRT ファイルの中身ですが、一度に表示する字幕に対し、通しのインデックス番号、開始時間 –> 終了時間、字幕テキスト、空行、で構成されています。以下はそのサンプルです (「政見放送」で検索した YouTube 動画から書き出した SRT ファイルの冒頭部分):

1
00:00:00,000 --> 00:00:02,460
自民党総裁の岸田文雄です

2
00:00:03,140 --> 00:00:06,900
私が目指すのは国民の声を受け止め

3
00:00:06,900 --> 00:00:11,880
寄り添い全力で挑む信頼と共感の政治です

開始と終了の時間は 2桁の時、分、秒で、カンマの後ミリ秒の整数部分が入っています。Whisper での書き出しであれば問題無いと思いますが、本アプリでは字幕部分が 2行以上あると正常に動作しません (手抜きです)。なので、そういう場合はテキストエディタで字幕部分を一行にまとめてください。Whisper では、音声認識がうまくいかなかったときに同じタイムスタンプの空行がいくつも出力されることがありますが、本アプリで読み込んだ際にそういう部分は自動で削除するようにしています。

コードの説明

以降では、実際のコードとその説明をしていきます。Flet の基本となる内容に関してはあまり深く触れません。また、実際の行番号より、理解しやすそうな順番で進めます。コードをエディタ等で開き、アプリも実行しながら見てもらえるとわかりやすいと思います。

Flet フレームワーク自体はコードの始めに ft としてインポートしています。

最終行 ft.app(target=main, assets_dir=”assets”) がアプリを作る

最後のこの行がアプリを作っています。Python ではあまり一般的な書き方ではありませんね。target=main で main 関数をアプリとして指定しています。assets_dir="assets" でコード本体と同じ階層にある assets フォルダを、画像などアプリで使用するファイル置き場として指定しています。最終的にはアプリケーションとしてビルドすることを見込んで、Flet アプリ本体のファイル名は main.py、コード内のアプリ定義の関数名は main、ファイル置き場のフォルダ名は assets にしておくのがお勧めです。アプリにする際には最低限の flet build macos (macOS の場合) と実行するだけでビルドできます。

ft.app(target=main, assets_dir="assets")

async def main 関数でウィンドウ自体を作り、overlay を追加

コードを実行したときに呼び出される関数です。Flet アプリの基本となる Page インスタンスを生成します。ウィンドウのタイトルや初期サイズ、カラーテーマの指定の後、通常は表示されない音声ファイルとダイアログ (ファイルの読み込み時に表示する OS 標準のウィンドウ) をオーバーレイとして追加します。

806行目で AudioSubPlayer のインスタンスを生成する際、音声ファイルを overlay.append する関数 load_audio を渡し、次の行でページに追加しています。こうすることで、クラス内部からページに音声ファイルを追加できます。

810-811行で、ファイルのオープンと書き出しの時に呼び出すダイアログをページに overlay.extend しています。

他に方法があるのかもしれませんが、ページに対する overlayUserControl クラス内から行うことができなかったため、こういう方法を取っています。

page.update() で、ページコントロールの更新 (描画) をします。Flet では何か見た目の変更を行った場合などは、対象のコントロールをアップデートすることで GUI に変化を与えます。ひとまとめの処理であればその最後にアップデートすれば OK です。なので、例えば 798 行目は不要ですね、ごめんなさい (すでにこの投稿の所々に行番号を書いてしまっているので、文章を優先して消しません)。他にもありそうですが、アプリの動作に影響ないはずなのでご勘弁ください。

対象のコードを開く
# Main function that builds window and adds page. Also, adds audio file and dialogs that are invisible as overlay.
async def main(page: ft.Page):
    page.title = 'Speech + Subtitles Player'
    page.window_height = 800
    page.theme_mode=ft.ThemeMode.SYSTEM
    page.update()

    # Appends audio as an overlay to the page.
    async def load_audio():
        page.overlay.append(app.audio1)
        page.update()

    # Creates an instance of AudioSubPlayer class. Passes load_audio for the instance to append audio to the page. 
    app = AudioSubPlayer(load_audio)
    page.add(app)

    # Adds dialog instance methods to the page.
    page.overlay.extend([app.pick_speech_file_dialog, app.pick_text_file_dialog, 
                         app.export_as_srt_dialog, app.export_as_txt_dialog])
    page.update()

アプリのメインとなる AudioSubPlayer クラスの中身

メインのクラスは、UserControl を継承しユーザ定義のコントロールとして実装しています。build() メソッド は UserControl に必須で、そこで UI を構築します。なので、まずは同メソッドの中身を見ていきましょう (実はこの UserControl は Flet バージョン 0.21.0 で obsoleted (廃止) となってしまっていました。ですが、とりあえず手元のバージョン 0.21.2 でも動いているのでそのまま利用と説明を続けます。この先も正式版リリースまでいろいろと大きな変更があるでしょうから、新しめのフレームワークを使うときは、リリースの内容確認が重要ですね)。

def build(self) で UI を設計

740行目から、column のインスタンス self.view としてユーザインタフェースを構築しています。手描きの図の一番大きな Column とその中身がここです。

公式のサンプルを見ていると、UI を構成するところにコントロールのプロパティや lambda 関数を書いていますが、実装する内容が増えるほどこの部分が読みづらくなります。なので、ここは極力レイアウトのみに絞って書いた方が後々楽だと思います。build() メソッドの最後に、定義した内容を return します。

Flet では UI の部品となるコントロールを書いていくと、上から順番に配置されることになります。なので、複数のコントロールを横に並べたいときは、Row の中にコントロールを入れてあげます。例えば、上から 2つめ (748行目) の Row にに、音声ファイルを開くためのボタンとファイル名を表示するテキストが入っているので、横並びに表示されます。

コーディング中はプロパティ (位置決めのアラインメントやカラー等の要素) をいろいろと試すことになると思うので、773-778 行目の様にそれらは単独の行として書き、最後に必ずコンマを追加しておきましょう。コメントしたり値を変更したりが楽にできます。最終的に確定したら 771 行目の様に 1行にまとめてしまえば良いでしょう。
対象のコードを開く
# === BUILD METHOD ===
def build(self):
    self.view = ft.Column(expand=True, controls=[
        ft.Container(content=
            ft.Column(controls=[
                ft.Row(controls=[
                    self.base_dir,
                ]),
                ft.Row(controls=[
                    self.speech_file_button,
                    self.speech_file_name,
                ]),
                ft.Row(controls=[
                    self.text_file_button,
                    self.text_file_name,
                    self.save_button,
                    #self.export_button,
                    self.export_as_srt_button,
                    self.export_as_txt_button,
                ]),
                self.audio_slider,
                ft.Row([
                    self.position_text,
                    self.duration_text,
                ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
                ft.Row(controls=[
                    self.rewind_button,
                    self.play_button,
                    self.faster_sw,
                    self.sub_scroller_sw,
                ]),
            ]), expand=False, border_radius=10, border=ft.border.all(1), padding=10, 
        ),
        ft.Container(content=
            self.subs_view,
            border_radius=10,
            border=ft.border.all(1),
            padding=5,
        ),
        ft.Row(controls=[
            ft.Text(text_align=ft.CrossAxisAlignment.START,
                    spans=[ft.TextSpan('© 2024 Peddals.com', url="https://blog.peddals.com")],
                    ), 
            ft.Image(src='in_app_logo_small.png'),
        ],alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
        ),
        ft.Container(content=
            self.notification_bar)
        ],
        )

    return self.view

def init(self, load_audio) メソッドで、全てのコントロールを定義・初期化

187行目からが初期化の部分で、まずクラス変数を初期化し、上に書いた音声ファイル読み込みのための関数を取り込んでいます。そしてその後 374行目までずらっと続くのが全てコントロールの定義・初期化です。個別に説明するのは大変なので大まかに説明すると、それおぞれ表示するテキストやアイコンといった見た目のプロパティと、イベントが発生したときに呼び出すメソッドが定義されています。

典型的なコントロール定義をボタンで説明

一般的な使い方の例として、テキストファイルを読み込むボタンコントロールの定義の中身を説明します。

        # Open text file button
        self.text_file_button = ft.ElevatedButton(
            text='Open SRT/TXT File',
            icon=ft.icons.TEXT_SNIPPET_OUTLINED,
            on_click=self.pre_pick_text_file,
            disabled=True,
            width=210,
        )

まず、これらの内容は初期値であると理解してください。プロパティは他のメソッドなどから変更することができるので、アプリが起動したときの状態を定義しています。

(237行目) self.text_file_button という名前で ft.ElevatedButton のインスタンスを作成 (ダークテーマだとわかりづらいですが、少し浮いたような見た目のボタン)。括弧の中にコンマで区切ってプロパティとメソッドを定義

(238行目) ボタンに表示するテキストを text プロパティで定義

(239行目) ボタンに含めるアイコンを icon プロパティで指定。アイコンの位置は左端で固定、変更方法ナシ。アイコンの見つけ方・名前の確認は、下記の囲み参照

(240行目) on_click イベントが発生した (ボタンがクリックされた) ときに呼び出すメソッドを指定

(241行目) アプリスタート時クリックできないように、disabled プロパティを True にしている。音声ファイルが読み込まれた後は False にし、クリックできるようにする

(242行目) ボタンの横幅を 210 ドットに固定

このボタンでは使っていませんが、tooltip プロパティを指定すると、マウスカーソルを上に持って行ったときに文字列表示することができます。

後でまた出てきますが、プロパティは self.text_file_button.disabled=False といった形でメソッドなどから定義し、update することで変更ができます。それぞれのコントロールで利用できるプロパティやメソッド、イベントはオフィシャルドキュメントを見てください。

アイコンはこのページで検索することができます。本記事執筆現在、残念ながら Safari では表示されたアイコンをクリックしても名前はコピーされません。Chrome を使うか、ホバーオーバーで表示される内容を手打ちで使う必要があります (Visual Studio Code はアイコン名も補完してくれます)。本アプリの様に Flet を ft としてインポートしている場合は、icon=ft.icons.THUMB_UP とします。
“thumb” で検索してヒットしたアイコン。ホバーオーバーで名前が出る

ほとんどのコントロールとそのプロパティなどは見ればわかると思いますが、ファイルを開いたり書き出したりする FilePicker を開くまでの流れは個人的にわかりづらかったので、別途説明します。

対象のコードを開く
    def __init__(self, load_audio):
        super().__init__()
        self.position = 0
        self.duration = 0
        self.isPlaying = False
        self.load_audio = load_audio

        # == Controls ==
        
        # Audio control with default properties
        self.audio1 = ft.Audio(
            src='',
            volume=1,
            balance=0,
            playback_rate=1,
            on_loaded=self.loaded,
            on_position_changed = self.position_changed,
            on_state_changed = self.playback_completed,
        )

        # Path of the audio file
        self.base_dir = ft.Text(value=f"Base Directory: ")

        # Open speech file button
        self.speech_file_button = ft.ElevatedButton(
            text='Open Speech File', 
            icon=ft.icons.RECORD_VOICE_OVER_OUTLINED, 
            width=210,
            on_click=self.pre_pick_speech_file,
        )

        # Speech file picker control
        self.pick_speech_file_dialog = ft.FilePicker(on_result=self.pick_speech_file_result)

        # Speech file name
        self.speech_file_name = ft.Text(value='← Click to open a speech file.')

        # Alert dialog that opens if subtitle was edited but not saved when Open Speech File button is clicked.
        self.speech_save_or_cancel_dialog = ft.AlertDialog(
            modal=True,
            title=ft.Text('Change not saved.'),
            content=ft.Text('Do you want to discard the change?'),
            actions=[
                #ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
                ft.TextButton('Open without save', on_click=self.open_speech_without_save, tooltip='Change will be lost.'),
                ft.TextButton('Cancel', on_click=self.close_speech_save_or_cancel_dialog),
            ]
        )

        # Open text file button
        self.text_file_button = ft.ElevatedButton(
            text='Open SRT/TXT File',
            icon=ft.icons.TEXT_SNIPPET_OUTLINED,
            on_click=self.pre_pick_text_file,
            disabled=True,
            width=210,
        )
        
        # Text file picker control
        self.pick_text_file_dialog = ft.FilePicker(on_result=self.pick_text_file_result)

        # Text file name
        self.text_file_name = ft.Text(value='No file selected.')

        # Save button to update edited subtitles. No dialog, it just overwrites current text file.
        self.save_button = ft.ElevatedButton(
            text='Save', 
            icon=ft.icons.SAVE_OUTLINED, 
            tooltip='Update current SRT/TXT file.',
            disabled=True,
            on_click=self.save_clicked
            )
        
        # Export as SRT button which opens a save dialog. Only available when SRT is open because SRT needs timestamp.
        self.export_as_srt_button = ft.ElevatedButton(
            text = 'SRT',
            icon=ft.icons.SAVE_ALT,
            on_click=self.export_as_srt,
            disabled=True,
            tooltip='Export as SRT file.'
        )

        # Export as SRT file picker
        self.export_as_srt_dialog = ft.FilePicker(on_result=self.export_as_srt_result)

        # Export as TXT button which opens a save dialog. TXT has not timestamp, subtitle text only.
        self.export_as_txt_button = ft.ElevatedButton(
            text = 'TXT',
            icon=ft.icons.SAVE_ALT,
            on_click=self.export_as_txt,
            disabled=True,
            tooltip='Export as TXT file.'
        )

        # Export as TXT file picker
        self.export_as_txt_dialog = ft.FilePicker(on_result=self.export_as_txt_result)

        # Export button to open a dialog (not in use)
        self.export_button = ft.ElevatedButton(
            text='Export as...', 
            icon=ft.icons.SAVE_ALT, 
            on_click=self.open_export_dialog,
            disabled=True,
            )
        
        # Export as dialog (not in use)
        self.export_dialog = ft.AlertDialog(
            modal = True,
            title = ft.Text('Export text as...'),
            content = ft.Text('Plesae select a file type.'),
            actions = [
                ft.TextButton('SRT', on_click=self.export_as_srt, tooltip='Subtitles with timestamps'),
                ft.TextButton('TXT', on_click=self.export_as_txt, tooltip='Subtitles only (no timestamps)'),
                #ft.TextButton('CSV', on_click=self.export_csv, tooltip='Comma separated value'),
                # I guess no one needs subtitles in CSV...
                ft.TextButton('Cancel', on_click=self.close_export_dialog),
            ],
            actions_alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
        )
        
        # Alert dialog that opens if subtitle was edited but not saved when Open SRT/TXT File button is clicked.
        self.text_save_or_cancel_dialog = ft.AlertDialog(
            modal=True,
            title=ft.Text('Change not saved.'),
            content=ft.Text('Do you want to discard the change?'),
            actions=[
                #ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
                ft.TextButton('Open without save', on_click=self.open_text_without_save, tooltip='Change will be lost.'),
                ft.TextButton('Cancel', on_click=self.close_text_save_or_cancel_dialog),
            ]
        )
        # Audio position slider
        self.audio_slider = ft.Slider(
            min = 0,
            value = int(self.position/10000),
            label = "{value}ms",
            on_change = self.slider_changed,
        )

        # Current playing position and duration of audio file
        self.position_text = ft.Text(value='Current position')
        self.duration_text = ft.Text(value='Duration (hh:mm:ss,nnn)')
        
        # Rewinds 5 seconds
        self.rewind_button = ft.ElevatedButton(
            icon=ft.icons.REPLAY_5,
            text="5 secs",
            tooltip='Rewind 5 secs',
            on_click=self.rewind_clicked,
            disabled=True,
        )

        # Play/Pause button. After loading audio file, this button will always be focused (space/enter to play/pause).
        self.play_button = ft.ElevatedButton(
            icon=ft.icons.PLAY_ARROW,
            text = "Play",
            on_click=self.play_button_clicked,
            disabled=True,
        )

        # 1.5x faster toggle switch
        self.faster_sw = ft.Switch(
            label='1.5x',
            value=False,
            on_change=self.playback_rate,
        )

        # Auto scroll toggle switch
        self.sub_scroller_sw = ft.Switch(
            label='Auto scroll',
            value=True,
        )
                
        # Area to add subtitles as buttons
        self.subs_view = ft.Column(
            spacing = 5,
            height= 400,
            width = float("inf"),
            scroll = ft.ScrollMode.ALWAYS,
            auto_scroll=False,
        )

        # Notification bar control at the bottom
        self.notification_bar=ft.SnackBar(
            content=ft.Text('Speech + Subtitle Player'),
            duration=2000,
            bgcolor=ft.colors.BLUE_GREY_700,
        )

クラスメソッド (ロジック) 部分

バージョン 0.21.0 から Flet は async-first framework となり、同期処理の必要が無い限り非同期で処理される async def という形で関数やメソッドを作ることが推奨 (?) となりました。アプリによっては応答性が良くなり、そのために細かいことを気にしなくても良くなったと考えて良いと思います。個人的には運悪く直前のバージョンで async を使って書き始め、途中で Flet をバージョンアップし、結果多くの書き直しが発生し大変でした…。ともあれ、378行目から 738行目のメソッド群はほぼ全て async def で定義し、self.update() で表示のアップデートを行っています。

以下、いくつか説明しておきたいメソッドの内容を書き連ねます。

音声ファイル読み込み後の処理 async def loaded(self, e)

378行目からのこのメソッドは、音声ファイルが読み込まれたら呼び出されます。各プロパティの変更や、字幕ファイルをアプリ内で利用するための変換処理などがあり、一つのメソッドしては一番長くなっています。

頭の 30行弱は割と単純に、スライダやテキスト、ボタンなどのコントロールのプロパティに値を入れたり、クリックできるようにしたりという事を行っています。まず最初の 3行はこんなことを行っています。

self.audio_slider.max = int(await self.audio1.get_duration_async())

Audio コントロールの持つ get_duration_async() メソッドを使い、音声としての長さ (ミリ秒) を取得し、左右に動くスライダ audio_slider コントロールのプロパティ max に代入しています。使用した Flet バージョン 0.21.2 で Audio コントロールの様な結果を返すメソッドを使用するには await ~ <method>_async() という書き方が必要となり、他の部分では見ないこのような書き方になっています。

self.duration_text.value = f'{ms_to_hhmmssnnn(self.audio_slider.max)}'

上で得たミリ秒を、00:00:00,000 という書式に変換してスライダの右端に表示するためのテキストとして代入しています。変換はコードの一番上にある関数 ms_to_hhmmssnnn() を使用しています (Copilot に書いてもらいました)。

self.audio_slider.divisions = self.audio_slider.max//1000

Slider コントロールの divisions プロパティでスライダを 1秒 (1000 ミリ秒) 間隔に分割しています。スライダは分割しないとマウスで動かしたときに数値が表示されません。また、Flet ではオーディオ経過のイベントは 1秒毎にしか発生しないため、このようにしています。実際のところ、スライダの数値はミリ秒表示から変えられなかったためこのアプリにおいては数値を表示することにほぼ意味はありません。

次の if ブロック (383行目~)で、字幕ファイルが見つかったときの処理をしています。create_subtitles() 関数に字幕ファイルを投げ、アプリ内で処理しやすいリストにして self.subtitles に格納しています。読み込まれたファイルがテキストの場合タイムスタンプが含まれないのですが、タイムスタンプのありなしそれぞれで処理を作るのが面倒だったのと見た目もサマにならないので、リストとして格納するときに全て 55:55:55,555 (201355555ミリ秒) にしています。コード内の所々でその値を見て処理を変えることに利用しています。5並びにしたのはなんとなくですが、約 56時間の音声ファイルを読み込むことは無いでしょうからヨシとしました。

この後 397-406行目までは主に、音声再生関連のボタンをクリックできるようにしています (本アプリでは基本的に再生・ポーズボタンを常にフォーカスしており、スペースやエンターキーで操作できます。当初、起動時には Open Speech File ボタンにフォーカスし、ファイルを読み終えたら再生ボタンにフォーカス、としたかったのですがうまくいきませんでした。あがいたときの残骸が 398-403行目あたりに残っています)。

408-433行目では、処理済みの字幕ファイルのリストを元に、タイムスタンプの無い TXT ファイルとタイムスタンプがある SRT ファイルそれぞれで諸々の有効・無効を調整しつつ、字幕一行ごとにボタンを作っています。実際にボタンの中身を作っているのは別クラスの SubButton() ですが、ここではそれを sub というインスタンスにし、self.subs_view.controls.append(sub) でアプリの画面下半分の領域に追加 (append) しています。

436-443行では、字幕ファイルのありなしに応じて画面最下部にメッセージを表示させています。Snackbar コントロールを使ったメソッド self.open_notification_bar にテキストだけを送るとただの通知で、字幕ファイルが見つからなかったときは警告色で長めに表示するように type='error' も付けて呼び出しています。

対象のコードを開く
    # Called once audio file is loaded. Enable/disable buttons, create subtitles list, etc.
    async def loaded(self, e):
        self.audio_slider.max = int(await self.audio1.get_duration_async())
        self.duration_text.value = f'{ms_to_hhmmssnnn(self.audio_slider.max)}'
        self.audio_slider.divisions = self.audio_slider.max//60
        # Enables buttons if associated text file exists.
        if self.text_file != 'No Text File.':
            # Call function to create the list of subtitles, self.subtitles.
            self.subtitles = create_subtitles(self.text_file)
            self.save_button.text = 'Save'
            self.save_button.disabled=False
            self.export_button.disabled=False
            self.export_as_srt_button.disabled=False
            self.export_as_txt_button.disabled=False
        # Disable buttons if associated text file does not eixt.
        else:
            self.save_button.disabled=True
            self.export_button.disabled=True
            self.export_as_srt_button.disabled=True
            self.export_as_txt_button.disabled=True
            self.subtitles = []
        self.speech_file_button.autofocus=False
        self.speech_file_button.update()
        self.play_button.disabled=False
        self.play_button.focus()
        self.play_button.autofocus=True
        self.play_button.update()
        self.rewind_button.disabled=False
        self.text_file_button.disabled=False
        self.subs_view.controls.clear()
        
        # Create buttons of subtitles from the list self.subtitles.
        if self.subtitles != []:
            # .txt or .srt file
            for i in range(len(self.subtitles)):
                index = self.subtitles[i][0]
                start_time = self.subtitles[i][1]
                # .txt file (timestap is dummy, 55:55:55,555) disable buttons.
                if self.subtitles[0][1]== 201355555:
                    self.sub_scroller_sw.value=False
                    self.sub_scroller_sw.disabled=True
                    self.export_dialog.actions[0].disabled=True
                    self.export_as_srt_button.disabled=True
                # .srt file
                else:
                    self.sub_scroller_sw.value=True
                    self.sub_scroller_sw.disabled=False
                self.sub_scroller_sw.update()
                end_time = self.subtitles[i][2]
                text = self.subtitles[i][3]
                
                # Create button instance of each subtitle. Include methods and controls for the instance to call or update.
                sub = SubButton(index, start_time, end_time, text, self.sub_time_clicked, self.play_button, 
                                self.save_button, self.subtitles)

                # Add button to the subtitle button area, subs_view.
                self.subs_view.controls.append(sub)

            # Call snackbar to show a notification.
            notification = f'Subtitle file loaded: {os.path.basename(self.text_file)}'
            await self.open_notification_bar(notification)
        
        # No text file found. Call snackbar to show an alert.
        else:
            notification = f'Subtitle file (.srt or .txt) not found.'
            await self.open_notification_bar(notification, type='error')
            print('Subtitle file not found.')

        self.update()

音声の再生位置が変更されたときの処理 async def position_changed(self, e)

447-454行のメソッドは、音声ファイルの再生位置が変更されたとき、つまり self.audio1 のイベント on_position_changed が発生したときに呼び出されます。具体的なケースは、再生時であれば自動的に 1秒ごと、それ以外では、ユーザがスライダの位置を変更したときと、タイムスタンプをクリックしたときに呼び出されます。コードを見ていきましょう。

self.audio_slider.value = e.data
#print("Position:", self.audio_slider.value)
self.position_text.value = ms_to_hhmmssnnn(int(e.data))

on_position_changed にはプロパティがあり、メソッドで e として受け取っています。e.data には再生位置 (経過時間) がミリ秒で入っていますので、ここではスライダの位置を変更するためにその値をaudio_slider コントロールの value プロパティに代入しています。また、position_text コントロールの value に読みやすい形に変換した値を入れています。これはスライダの左端に表示されることになります。

if (self.sub_scroller_sw.value == True) and (self.text_file_name.value != 'No Text File.'):
   self.scroll_to(self.audio_slider.value)
self.update()

ここで判定しているのは、字幕のオートスクロールのスイッチの状態と、字幕ファイルの有無です。字幕ファイルは、読み込めなかったときに “No Text File.” と表示させているので、表示内容自体をフラグとして利用しています。そして、それぞれが真の場合、字幕をスクロールするメソッドscroll_toに引数self.audio_slider.valueを渡しています。最後のself.update()で、ここのメソッド自身としては再生時間の更新がされます。

スライダ位置が変わったときの処理 async def slider_changed(self, e)

457-460行のメソッドはスライダの位置が変更されたとき、つまりself.audio_sliderコントロールのメソッドon_changeが発生したときに呼び出されます。

self.audio1.seek(int(self.audio_slider.value))

self.audio1のメソッドseekにスライダの位置の値 (self.audio_slider.value) を渡すことで、再生位置を変更します。後はアップデートで反映するだけなので、オーディオ再生位置の変更は非常に簡単です。

ところで、メソッドとしては引数eを受け取ってはいますが、e.valuee.dataもスライダの位置情報として使えなかったので、直接スライダ位置の値を渡しています。また、使わないからといってメソッドでeを受け取らない場合も期待した動作はしません。結局コード全体を書き終えるまで引数の受け渡し方 (eの要不要と中身、取り出し方) に関して 100% は理解できなかったので、トライアル&エラーで正解を見つけるまで多くの時間がかかりました。

再生ボタン async def play_button_clicked(self, e) と async def playback_completed(self, e)

463-488行では、Play (再生) ボタンにまつわる処理をしています。音声ファイルを読み込んだとき、再生中、一停止中、そして再生が終了したときのそれぞれで、self.audio1のメソッドを使って、ボタンクリックによる再生や一時停止を行います。また、アイコンや文言も変更しています。

e.dataで再生中 (playing) などのステータスを得られそうだったのですがうまくいかず、クラス変数self.isPlayingを作って判定することにしました。ボタンとしては、( Play / Pause ) の様に常に同じ内容を表示していても良かったのですが、状況に応じたアイコンを表示したいという色気の他、デバッグの際にステータスを見たかったという側面もあり、こうなりました。

対象のコードを開く
    # Change Play/Pause status and icon when called.
    async def play_button_clicked(self, e):
        self.position = await self.audio1.get_current_position_async()
        if (self.isPlaying == False) and (self.position == 0):
            self.audio1.play()
            self.isPlaying = True
            self.play_button.icon=ft.icons.PAUSE
            self.play_button.text = "Playing"
        elif self.isPlaying == False:
            self.audio1.resume()
            self.isPlaying = True
            self.play_button.icon=ft.icons.PAUSE
            self.play_button.text = "Playing"
        else:
            self.audio1.pause()
            self.isPlaying = False
            self.play_button.icon=ft.icons.PLAY_ARROW
            self.play_button.text = "Paused"
        self.update()
    
    # When audio playback is complete, reset play button and status.
    async def playback_completed(self, e):
        if e.data == "completed":
            self.isPlaying = False 
            self.play_button.icon=ft.icons.PLAY_ARROW
            self.play_button.text = "Play"
        self.update()

巻き戻しと 1.5倍速 async def rewind_clicked(self, e) と async def playback_rate(self, e)

491-507行で、5秒巻き戻しボタンと 1.5倍速再生スイッチの処理をしています。巻き戻しは値がマイナスにならない様にしているだけの、簡単な処理です。1.5倍速も、スイッチがオンの場合に Audio コントロールが持つplayback_rateメソッドに 1.5 を代入するだけなので、非常に簡単です。一点、速度の変更後は Audio コントロールのアップデートをするため、await self.audio1.update_async() とする必要があるところが注意です。

アプリ設計の基本的として、極力シンプルで直感的に操作できるデザインにすることを心がけました。また、必要な機能のみを追加するということもにも気をつけました。巻き戻しボタンは必要な機能に含まれます。実際に自分が字幕の編集作業をしているとき、一時停止を忘れることがちょいちょいありました。また字幕は基本的に再生中の部分が一番上に来るので過ぎた字幕は見えず、音声の再生中でもちょっとだけ戻せるボタンは便利だとわかりました。長めに戻したければ何度かクリックすれば良いのです。3秒や 6秒じゃない理由は単純で、使用したアイコンに 3や 6がない、というデザイン的な理由です。1.5倍速スイッチは、流行の時短にあわせたものです。2倍速も試しましたが個人的にはちょっと無理があるように感じたので 1.5にしました。iOS と macOS は 0.5~2 の範囲という制限がありますが、時短指向の強い方や用途に応じて 503行目の self.audio1.playback_rate = 1.5 を変更してみてください。
対象のコードを開く
    # When 5 secs button is clicked, rewind 5 seconds.
    async def rewind_clicked(self, e):
        if self.audio_slider.value <= 5*1000:
            self.audio_slider.value = 0
        else:
            self.audio_slider.value -= 5*1000
        self.audio1.seek(int(self.audio_slider.value))
        #print(int(self.audio_slider.value))
        self.update()
    
    # Switch playback rate between normal and 1.5x faster.
    async def playback_rate(self, e):
        if self.faster_sw.value == True:
            self.audio1.playback_rate = 1.5
        else:
            self.audio1.playback_rate = 1
        #print(f'Playback rate: {self.audio1.playback_rate}')
        await self.audio1.update_async()

タイムスタンプボタン async def sub_time_clicked(self, start_time)

510-514行では、SRT ファイルを読み込んだときに有効となるタイムスタンプボタンをクリックしたときに、その文字列の部分を再生する処理をしています。停止中の場合であれば再生を始めます。

このタイムスタンプボタン自体は、もう一つのクラスSubButtonで生成されています。インスタンス生成の際に本メソッドを渡してあり、クリックによってSubButtonクラスのjump_clicked()メソッドからボタン自身のstart_timeを受け取り、Audio コントロールのseekメソッドで頭出しをする、という動作を行います。

タイムスタンプボタンクリックで再生位置を変化させることを実現しているコードとその説明を順番に見ていきましょう。

# Create button instance of each subtitle. Include methods and controls for the instance to call or update.
sub = SubButton(index, start_time, end_time, text, self.sub_time_clicked, self.play_button, 
                self.save_button, self.subtitles)

インスタンスを作るコード部分。本メソッドself.sub_time_clicked を渡しています。

# Create button of subtitle text.
class SubButton(ft.UserControl):
    def __init__(self, index, start_time, end_time, text, sub_time_clicked, play_button, save_button, subtitles):
        super().__init__()
        # Parameter of each subtitle.
        self.index = index
        self.start_time = start_time
        self.end_time = end_time
        self.text = text
        # Passed methods and controls to call and update.
        self.sub_time_clicked = sub_time_clicked

ボタンを作るもう一つのクラスSubButtonの初期化部分 (一部)。親クラスから引き継いだオブジェクトを自身のオブジェクトとして格納しています。ハイライトが今回使われている部分です。

# When timestamp clicked calls AudioSubPlayer.sub_time_clicked to jump to button position.
async def jump_clicked(self, e):
    await self.sub_time_clicked(self.start_time)

on_clickイベントでタイムスタンプボタンがクリックされたときに呼び出されるメソッド。self.start_timeself.sub_time_clickedを使って、親クラスのメソッドを実行しています。

そして最終的に本メソッドでstart_timeの位置の音声を再生します。

# When the timestamp is clicked, jump to its position and play if not playing.
async def sub_time_clicked(self, start_time):
    self.audio1.seek(int(start_time))
    if self.isPlaying == False:
        await self.play_button_clicked(start_time)
    self.update()
Python やクラスを理解していればそれだけのことですが、入門書数冊を最後まで読み切っていない自分には、インスタンスから実行中クラスのメソッドを実行する方法にたどり着くまでにかなり時間がかかってしまいました。簡単にググって済まそうにも、自分のやりたいことを検索に引っかかるように言語化すること自体が困難でした。GOTO/GOSUB で知識が止まっている絶滅危惧種の様な方は、Python のクラスはしっかり勉強することをお勧めします。

字幕のスクロール async def scroll_to(self, e)

517-525行で、字幕のスクロールを行っています。タイムスタンプを持つ SRT ファイルを開いているときだけposition_changedメソッドから呼び出されます。引数eには音声の再生位置 (ミリ秒) が渡されています。クラス変数self.subtitlesは 2次元のリストで、内側のリストに、連番のインデックス、開始時間、終了時間、テキストが格納されており、本メソッドではインデックスindexと終了時間end_timeを参照します。

ここでやりたいのは、再生中の音声に該当した字幕を一番上に移動することです。ただし、Flet が音声ファイルの再生位置を取り出すのは 1秒毎なので、その値より大きくて一番近いend_timeを持つ字幕ボタンにスクロールさせます。リアルタイムで完璧には同期していませんが、一番上ないしは二番目には再生中の字幕部分が現れる状態となります。順番に見ていきましょう。コード、説明の順です。

end_time = [item[2] for item in self.subtitles]

リスト型ローカル変数end_timeに、全字幕の終了時間を代入します。

index = min(range(len(end_time)), key=lambda i: abs(end_time[i]-e))

ローカル変数indexに、現在の再生位置に一番近いend_timeを持った字幕自身の位置を代入します。index は 0から始まる整数です。

key=str(self.subtitles[index][0])

ローカル変数keyに SRT ファイル内のインデックス番号を str に変換して代入します。SRT ファイルのインデックス番号は 1から始まり、必ずしも連番ではなく途中のインデックス番号が抜け落ちている可能性を考慮し、ひと手間入れています (実際のところ、ここを書いた後にアプリ内部で使用するsubtitlesリストを生成するコードでインデックスを連番になるようにしたため、key=str(index+1) でも同じ動作になります)。

self.subs_view.scroll_to(key=key, duration =1000)

Column インスタンスであるsubs_viewのメソッドscroll_toを使用し、ある程度のなめらかさ (duration =1000) でkey=keyとなっているボタンまでへスクロールさせます (イコールの左のkeyscroll_toのプロパティで、右のkeyはインデックス番号を文字列で持っているローカル変数です)。

ここの説明に追加したいことは 2点あります。ひとつ目は、ボタンが多くなると Flet アプリの動作が重くなるということです。特に 300個以上のボタンがあると、ウィンドウの移動は挙動がおかしくなります。CPU やメモリの負荷・使用量などは問題無いので、Flet の仕様的な理由のような感じがします。多くのリストを使用するアプリの制作を考えている方は、別のコントロールを使用することを検討した方が良いと思います。ボクが調べたときは、スクロールとクリックできるものが他には見つかりませんでしたが、何らかの工夫で対処できた可能性はあります。もうひとつは、NumPy の使用に関してです。Python から実行する場合 (python main.py) は問題ありませんが、Flet バージョン 0.21.2 では macOS 用に NumPy を使用するコードをビルド (flet build macos) すると、ビルドされたアプリがクラッシュする問題があります。本アプリも当初は NumPy を使用 (520行目) していたのですが、書き直しました。そのことについては別の記事にまとめてあります。→ NumPy ビルド問題は解決しました

音声ファイルの読み込み async def pre_pick_speech_file(self, e) からの流れ

ここからは、音声ファイルの読み込みに関連するメソッドとコントロール一式を説明していきます。OS の機能を利用することで楽ができている面もありますが、Flet または FilePicker コントロールがこなれていないからなのか、「ファイルを開く・保存する」を実現するために必要なものは多いです。ファイルを開くためには、大まかにこれだけのことをする必要があります:

  1. ダイアログコントロールのインスタンスを作る
  2. 同インスタンスを、ページに追加する
  3. 「ファイルを開く」ダイアログを開くイベントを発生させるボタンを作り、ページに配置する
  4. ファイルを選択したイベントを受け取り処理するメソッドを作る

本アプリの場合はさらに、字幕のテキストに未保存の変更があった場合には変更を破棄するかそのまま開くかを聞くダイアログを表示させており、そのためのメソッドがひとつ、そのダイアログを閉じるメソッドがひとつ、そしてそれら一式を音声ファイルとテキストファイルそれぞれに用意することとなりました。ほぼ同じ処理をしているので再利用できるように書くべきですが、実際は手を抜いたのでそれぞれの細かいコードが存在している状態です。いずれにせよ、ファイルの読み書きのために必要なことが多く、それぞれで行ったり来たりがあるため、自分にとっては一番面倒な工程でした。以下、なるべく実際の動作に従って順番にコードを説明していきます。

# Speech file picker control
self.pick_speech_file_dialog = ft.FilePicker(on_result=self.pick_speech_file_result)

OS の「ファイルを開く」ダイアログを開くFilePickerコントロールのインスタンスです。実際にファイルが選択された際にon_resultイベントが発生し、self.pick_speech_file_resultメソッドが呼ばれます (そこにたどり着くまでが長いです)。

# Adds dialog instance methods to the page.
page.overlay.extend([app.pick_speech_file_dialog, app.pick_text_file_dialog, 
                     app.export_as_srt_dialog, app.export_as_txt_dialog])

ページにoverlay.extendで全てのファイルの読み書きに使用するダイアログを追加しています。これは Audio コントロールの追加同様、クラスの外側、async def main() の中で行います。

# Open speech file button
self.speech_file_button = ft.ElevatedButton(
    text='Open Speech File', 
    icon=ft.icons.RECORD_VOICE_OVER_OUTLINED, 
    width=210,
    on_click=self.pre_pick_speech_file,
)

クリックされたときにself.pre_pick_speech_fileを呼び出すボタンです。

# Called once Open Speech File button is clicked to pause playback and check if changes saved.
async def pre_pick_speech_file(self, e):
    if self.isPlaying == True:
        await self.play_button_clicked(e)
    if self.save_button.text == '*Save':
        #print('Save is not done.')
        await self.speech_save_or_cancel()
    else:
        await self.pick_speech_file()

このメソッドでは、実際に「ファイルを開く」ダイアログを開く前に行いたい処理を書いています。まず、再生中であれば停止します。そして字幕に未保存の変更があれば (「*Save」 という表示になっている場合) セーブを促すダイアログを表示するメソッドを呼び、無ければやっと「ファイルを開く」ダイアログ部分のメソッドを呼びます。ここでは全てのメソッド呼び出しにawaitが要求されました。一時停止するためにself.play_button_clicked(e)を呼んでいますが、同メソッドは (使っていなくても) 引数を要求するのでeを渡しています。未保存の変更は、セーブボタンにアスタリスクがついていれば有り、と判定しています。

# Opens a dialog if change is not saved.
async def speech_save_or_cancel(self):
    self.page.dialog = self.speech_save_or_cancel_dialog
    self.speech_save_or_cancel_dialog.open = True
    self.page.update()

未保存の変更があったときに呼ばれるメソッドです。中身としては、ページのダイアログにAlertDialogのインスタンス (次のself.speech_save_or_cancel_dialog) を指定し、openプロパティを有効にすることでダイアログを表示しています。

# Alert dialog that opens if subtitle was edited but not saved when Open Speech File button is clicked.
self.speech_save_or_cancel_dialog = ft.AlertDialog(
    modal=True,
    title=ft.Text('Change not saved.'),
    content=ft.Text('Do you want to discard the change?'),
    actions=[
         #ft.TextButton('Save', on_click=self.save_then_open, tooltip='Save then open another file.'),
         ft.TextButton('Open without save', on_click=self.open_speech_without_save, tooltip='Change will be lost.'),
         ft.TextButton('Cancel', on_click=self.close_speech_save_or_cancel_dialog),
    ]
)

未保存の変更があるときに開くダイアログです。セーブせずにファイルを開く Open without save と、キャンセルしてダイアログを閉じる Cancel ボタンが配置されています。ここからセーブもできるようにしたかったのですがうまくいかず、Save ボタンはコメントで残っています。

# Closes the above dialog.
async def close_speech_save_or_cancel_dialog(self, e):
    self.speech_save_or_cancel_dialog.open = False
    self.page.update()

先にキャンセルの処理です。単純にダイアログのopenプロパティを False にして閉じています。

# Opens audio file pick.
async def open_speech_without_save(self, e):
    self.speech_save_or_cancel_dialog.open = False
    self.page.update()
    await self.pick_speech_file()

ダイアログでセーブせずにファイルを開くことを選択した際に呼ばれるメソッドです。ダイアログを閉じページをアップデートしてから、self.pick_speech_file()を呼びます。

# Opens audio file pick dialog. Only allow compatible extensions.
async def pick_speech_file(self):
    self.pick_speech_file_dialog.pick_files(
        dialog_title='Select a speech (audio) file',
        allow_multiple=False,
        allowed_extensions=['mp3', 'm4a', 'wav', 'mp4', 'aiff', 'aac'],
        file_type=ft.FilePickerFileType.CUSTOM,
    )

ついに「ファイルを開く」ダイアログを開くメソッドの登場です。2つのプロパティallowed_extensionsfile_typeで、開けるファイルの拡張子を限定しています。このメソッドは、上で定義したself.pick_speech_file_dialogコントロールのpick_files()メソッドで「ファイルを開く」ダイアログを開いています。そして、ファイルが選ばれた場合、やっとon_resultイベントが発生し、self.pick_speech_file_resultメソッドが呼ばれます。OS の機能を呼び出しているため、前回開いたフォルダを Flet アプリ内のどこかで保持しておくというようなことは必要ありません。次回ファイルを開くときも同じフォルダが開きます。

# Called when audio file pick dialog is closed. If file is selected, call self.check_text_file to load text file.
async def pick_speech_file_result(self, e: ft.FilePickerResultEvent):
    if e.files:
        #print(f'e.files = {e.files}')
        self.speech_file_name.value = ''.join(map(lambda f: f.name, e.files))
        self.speech_file = ''.join(map(lambda f: f.path, e.files))
        #print(f'Full path= {self.speech_file}')
        self.audio1.src = self.speech_file
        self.base_dir.value=f"Directory: {os.path.dirname(self.speech_file)}"
        await self.check_text_file()
        self.update()
        await self.load_audio()

このメソッドには開かれたファイルの情報ft.FilePickerResultEventeとして取り込まれます。e.filesの中からf.nameでファイル名を取り出し、f.pathでフルパスを取り出します。それぞれを、ウィンドウの表示用にself.speech_file_name.valueと、音声ファイルself.audio1.srcとして読み込むためにself.speech_fileへ代入しています。await self.check_text_file() でテキストファイルの有無を確認する下で説明するメソッドを呼び出し、表示をアップデートした後に音声ファイルの読み込みを行う関数self.load_audio()を呼び出します。はぁはぁ。

# Checks if audioFileName.srt or .txt exists to automatically load it.
async def check_text_file(self):
    #print(f'Speech file = {self.speech_file}')
    tmp_file = os.path.splitext(self.speech_file)[0]
    if os.path.exists(tmp_file+'.srt'):
        self.text_file = tmp_file+'.srt'
        self.text_file_name.value = os.path.basename(self.text_file)
    elif os.path.exists(tmp_file+'.txt'):
        self.text_file = tmp_file+'.txt'
        self.text_file_name.value = os.path.basename(self.text_file)
    else:
        self.text_file = self.text_file_name.value = 'No Text File.'
        self.save_button.disabled=True
        self.export_button.disabled=True
        self.sub_scroller_sw.disabled=True
    #print(f'Subtitle file = {self.text_file_name.value}')

音声ファイルを選択した後に、同名で拡張子が .srt もしくは .txt のファイルがあれば読み込む準備をするメソッドです。どちらも無い場合はセーブなどのボタンを無効にします。

この後、クラスの外側にある 801行目のself.load_audio()が呼ばれ、音声ファイルをページに追加します。音声ファイルの読み込みが終わるとself.audio1のイベントon_loadedが発生し、メソッドの最初に説明したself.loadedが呼ばれるという流れになります。

拡張子を信頼してファイルの中身の評価をせずにこの長さです。手順を理解すれば難しい訳では無いのですが、頭の中だけで進めると結構大変です。ボクが字幕ファイルの読み込みのためにもう一セット同じ様なたくさんのコードを追加ときは、Smartsheet の無料版にチェックリストを作ってコーディングを進めました。すでに動いている音声ファイルの読み込みコードに手を入れて動かなくなったら面倒だ、という考えがあったからなのですが、今思えば再利用できるコードを書くべき典型例だったかもしれません。何のための GitHub 導入だったやら。というわけで、本記事ではテキストファイルの読み込みに関わる部分は触れないのであしからず。

字幕ファイルの上書き保存メソッド呼び出し async def save_clicked(self, e)

641-651行のこのメソッドでは、開いている字幕ファイルへ変更内容を上書き保存するメソッドを呼び出します。字幕の変更がされたときだけセーブボタンが「*Save」の表示になっているので、.srt または .txt の拡張子に応じた上書き処理のメソッドを呼びます。

対象のコードを開く
    # Updates current open file.
    async def save_clicked(self, e):
        #print(f'File: {self.text_file}')
        extension = os.path.splitext(self.text_file)[1]
        #print(f'Extension: {extension}')
        if self.save_button.text==('*Save'):
            if extension == '.srt':
                await self.save_as_srt(self.text_file)
            elif extension == '.txt':
                await self.save_as_txt(self.text_file)
            self.save_button.text=('Save')
        self.update()

SRT ファイルの上書き保存 async def save_as_srt(self, save_file_name)

670-684行のこのメソッドでは、SRT ファイルの上書き保存を行います。save_file_nameに開いたときのファイル名がフルパスで入っています。self.subtitlesは、アプリ内部で使いやすいように整形したリストなので、そこからインデックス番号、開始時間 –> 終了時間、字幕テキストと空行 (ただの\n)、という形で書き込みます。書き終わるとウィンドウ下部に表示するメッセージを送って描画を更新します。

対象のコードを開く
    # Saves as .srt file.
    async def save_as_srt(self, save_file_name):
        with open(save_file_name, 'w') as srt:
            for i in self.subtitles:
                for j in range(len(i)):
                    if j % 4 == 0:
                        srt.write('%s\n' % i[j])
                    elif j % 4 == 1:
                        start = ms_to_hhmmssnnn(int(i[j]))
                        end = ms_to_hhmmssnnn(i[j+1])
                        srt.write(f'{start} --> {end}\n')
                    elif j % 4 == 3:
                        srt.write('%s\n\n' % i[j]) 
        notification = f'Subtitle saved as an SRT file: {os.path.basename(save_file_name)}'
        await self.open_notification_bar(notification)
        self.update()

TXT ファイルの上書き保存 async def save_as_txt(self, save_file_name)

705-713行のこのメソッドでは、TXT ファイルの上書き保存を行います。SRT ファイルと違い字幕部分の文字列しかありませんので、self.subtitles リストの文字列部分のみを全て上書きします。書き終わるとウィンドウ下部に表示するメッセージを送って描画を更新します。

対象のコードを開く
    # Saves as .txt file.
    async def save_as_txt(self, save_file_name):
        with open(save_file_name, 'w') as txt:
            for i in self.subtitles:
                for j in range(len(i)):
                    if j % 4 == 3:
                        txt.write('%s\n' % i[j]) 
        notification = f'Subtitle saved as a TXT file: {os.path.basename(save_file_name)}'
        await self.open_notification_bar(notification)
        self.update()

SRT/TXT で書き出し async def export_as_srt(self, e) と async def export_as_txt(self, e)

654-667行の SRT 書き出しと、687-702行の TXT 書き出しも、まとめられたはずの手抜き部分です。それぞれのボタンがクリックされると、同名のファイルがある場合はファイル名に年月日時分を追加してファイル保存のダイアログを開きます。TXT ファイルを開いているときにはタイムスタンプ情報が無いため、書き出せるのは TXT のみとなります。

TXT を開いたときは SRT には書き出せない

ファイルの読み込み同様、ファイル名や保存先をユーザが指定できるようにするダイアログを開く場合も、コントール、ページへの追加、そして処理を行うメソッドと、複数に分かれたコードが必要となります。流れは音声ファイルの読み込みとほぼ同じなので詳細は書きませんが、クリックしたボタンに応じて最終的には上で紹介したsave_as_srtまたはsave_as_txtメソッドでファイルへの書き込みを行っています。

SRT で書き出すメソッドのコードを開く
# Exports current open SRT file as another SRT file.
async def export_as_srt(self, e):
    if os.path.splitext(self.text_file)[1] == '.srt':
        suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'_'+datetime.now().strftime("%Y%m%d%H%M")+'.srt'
    self.export_as_srt_dialog.save_file(
        dialog_title='Export as an SRT file',
        allowed_extensions=['srt'],
        file_name = suggested_file_name,
        file_type=ft.FilePickerFileType.CUSTOM,
    )

# Checks result of Export as SRT File Picker and passes absolute path to self.save_as_srt if exists.
async def export_as_srt_result(self, e: ft.FilePicker.result):
    if e.path:
        await self.save_as_srt(e.path)
TXT で書き出すメソッドのコードを開く
# Exports current open text file as a TXT file.
async def export_as_txt(self, e):
    if os.path.exists(os.path.splitext(self.text_file)[0]+'.txt'):
        suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'_'+datetime.now().strftime("%Y%m%d%H%M")+'.txt'
    else:
        suggested_file_name = os.path.basename(self.text_file).split('.', 1)[0]+'.txt'
    self.export_as_txt_dialog.save_file(
        dialog_title='Export as a TXT file',
        allowed_extensions=['txt'],
        file_name = suggested_file_name,
        file_type=ft.FilePickerFileType.CUSTOM,
    )

# Checks result of Export as TXT File Picker and passes absolute path to self.save_as_txt if exists.
async def export_as_txt_result(self, e: ft.FilePicker.result):
    if e.path:
        await self.save_as_txt(e.path)

ウィンドウ下部の通知 async def open_notification_bar(self, notification, type=’normal’)

716-725行のメソッドで、ウィンドウ下部に通知を表示します。この機能は Flet のSnackBarコントロール (370行目で定義) を利用しており、通知するときだけ表示し、自動的に消えます。通知内容のサンプル:

このメソッドはnotificationとして表示するテキストと、normal または error を持った type を待ち受け、typeが指定されていない場合はコントロールを生成したときのデフォルトの長さ (2秒) で通知を表示します。error の場合は中身が読めるよう注意喚起の黄色い通知内容を長め (4秒) 表示しています。色の指定方法はいろいろあるのですが、ボクはこちらのページで確認できる、名前の付けられた色を使いました。文字色の指定はSnackBarcontentプロパティに指定するTextコントロールのプロパティで、通知領域の色指定はSnackBar自体のプロパティbgcolorで行うところが直感的では無いかもしれません。内容を指定した後にプロパティでopen=Trueとしアップデートすると、指定された時間表示して消えます。

コントロールの指定部分は以下で、メソッド部分は下の 715行目からとなります。

    # Notification bar control at the bottom
    self.notification_bar=ft.SnackBar(
        content=ft.Text('Speech + Subtitle Player'),
        duration=2000,
        bgcolor=ft.colors.BLUE_GREY_700,
    )

今見返すと、上のコントロールの生成で通知領域の色指定をしているので、メソッド側での指定は不要でした。。。

    # Opens notification bar with given text. If type is 'error', shows message longer with caution color.
    async def open_notification_bar(self, notification, type='normal'):
        if type == 'normal':
            self.notification_bar.content=ft.Text(notification, color=ft.colors.LIGHT_BLUE_ACCENT_400)
            self.notification_bar.bgcolor=ft.colors.BLUE_GREY_700
        elif type == 'error':
            self.notification_bar.content=ft.Text(notification, color=ft.colors.RED)
            self.notification_bar.bgcolor=ft.colors.YELLOW
            self.notification_bar.duration=4000
        self.notification_bar.open=True 
        self.notification_bar.update()

字幕ボタンを作る SubButton クラスの中身

字幕やタイムスタンプからボタンを作るクラスは、UserControl を継承しユーザ定義のコントロールとして実装しています。このクラスは、公式の Tutorial にある To-Do app をコピーしたものを手直しして作りました。というわけで、メインのクラスとは大まかな中身の順番が異なっており、初期化、レイアウトのbuild、ボタンがクリックされたときのメソッド、という順番で並んでいます。

メソッドの初期化 def init(self, index, start_time, end_time, text, sub_time_clicked, play_button, save_button, subtitles)

親クラスがボタンのインスタンスを作るとき、インデックス番号、字幕の開始時間、字幕のテキストといった表示に関わる部分のほか、親クラスのメソッドやセーブボタン、も渡しています。読み込んだ字幕を二次元リストとして持っているsubtitlesは、テキストを編集したときに直接リストを操作できるように渡しています。中身も個別に渡しているので無駄があり、その点は参考にしないでください。

対象のコードを開く
def __init__(self, index, start_time, end_time, text, sub_time_clicked, play_button, save_button, subtitles):
    super().__init__()
    # Parameter of each subtitle.
    self.index = index
    self.start_time = start_time
    self.end_time = end_time
    self.text = text
    # Passed methods and controls to call and update.
    self.sub_time_clicked = sub_time_clicked
    self.play_button = play_button
    self.save_button = save_button
    self.subtitles = subtitles

字幕ボタン等のレイアウト def build(self)

97-150行では、コントロールのインスタンス生成と初期化をし、親クラスにリターンしています。前半の 123行目までで、タイムスタンプのボタン、字幕のボタン、字幕編集モード用のプレイスホルダを作り、表示用のコントロールself.display_viewとしてまとめています。

self.display_start_timekeyはインデックス番号で、タイムスタンプがクリックされたときのスクロールの飛び先指定となります。

126行目からのifブロックでは、読み込まれた字幕ファイルの種類に応じてタイムスタンプボタンにホバーオーバしたときのポップアップ (tooltip) を書き換えています。

132-149行は字幕テキストの編集モードの設定をしています。デフォルトではvisible=Falseで非表示になっています。

参考にしたオフィシャルの To-Do app には、それぞれのアイテムに編集と削除ボタンがありますが、本アプリでは削除ボタンは不要なので無くし、編集ボタンは字幕自体をボタンにすることで直感的に操作できるようにしました。また、編集モードをキャンセルで抜けられるようにすることで、アンドゥの実装を避けました。

字幕テキストのクリックで編集モードへ。エンターキーで確定し閉じる
対象のコードを開く
# === BUILD METHOD ===
def build(self):
    # Start time button
    self.display_start_time = ft.TextButton(text=f"{ms_to_hhmmssnnn(int(self.start_time))}",
                                        # Disable jump button if loaded text is TXT, no timestamp.
                                        disabled=(self.start_time==201355555),
                                        # When enabled, jump to the key when clicked.
                                        key=self.index,
                                        width=130,
                                        on_click=self.jump_clicked,)

    # Subtitle text button in display view. Click to edit.
    self.display_text= ft.TextButton(text=f"{self.text}", 
                                     on_click=self.edit_clicked, 
                                     tooltip='Click to edit')

    # Placeholder of subtitle text button in edit view.
    self.edit_text = ft.TextField(expand=1)

    # Put controls together. Left item is the key=index.
    self.display_view = ft.Row(
        alignment=ft.MainAxisAlignment.START,
        controls=[
            ft.Text(value=self.index, width=30),
            self.display_start_time,
            self.display_text,
        ]
    )

    # Change tool tip of start time button which is only clickable for SRT.
    if self.start_time==201355555:
        self.display_start_time.tooltip='Jump not available'
    else:
        self.display_start_time.tooltip='Click to jump here'

    # Subtitle edit view visible when clicked.
    self.edit_view = ft.Row(
        visible=False,
        #alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
        #vertical_alignment=ft.CrossAxisAlignment.CENTER,
        controls=[
            self.edit_text,
            ft.IconButton(
                icon=ft.icons.DONE_OUTLINE_OUTLINED,
                tooltip='Update Text',
                on_click=self.save_clicked,
            ),
            ft.IconButton(
                icon=ft.icons.CANCEL_OUTLINED,
                tooltip='Close wihout change',
                on_click=self.cancel_clicked,
            )
        ]
    )
    return ft.Column(controls=[self.display_view, self.edit_view])

字幕編集モード async def edit_clicked(self, e)

155-161行では、字幕ボタンがクリックされたときの編集モードの有効化をしています。focus()メソッドを呼ぶことですぐにキーボードで操作が行えるようにし、on_submitイベントによりエンターキーでself.save_clickedメソッドを呼び出せるようにしています。

対象のコードを開く
# Opens editable text button with subtitle. Hit enter key or click checkmark to call save_clicked.
async def edit_clicked(self, e):
    self.edit_text.value = self.display_text.text
    self.edit_text.focus()
    self.display_view.visible = False
    self.edit_view.visible = True
    self.edit_text.on_submit = self.save_clicked
    self.update()

編集した字幕の上書き async def save_clicked(self, e)

164-172行で、チェック (上書き) ボタンクリックもしくはエンターキーで字幕の編集が確定されたときの処理を行っています。セーブボタンには編集済みを示すアスタリスクを付け (*Save)、字幕のリストであるself.subtitlesにも変更内容を上書きします。フォーカスを音声ファイルの再生ボタンに戻し、スペース or エンターで再生・停止ができるようにしています。

対象のコードを開く
# Updates edited subtitle, change save button, revert focus back to Play button.
async def save_clicked(self, e):
    self.display_text.text= self.edit_text.value
    self.display_view.visible = True
    self.edit_view.visible = False
    self.save_button.text = '*Save'
    self.subtitles[int(self.index)-1][3]=self.display_text.text
    self.play_button.focus()
    self.save_button.update()
    self.update()

字幕編集のキャンセル async def cancel_clicked(self, e)

175-179行では、(×) ボタンがクリックされたときの編集キャンセル処理をしています。とは言っても単純に変更内容を破棄して編集モードを終了し、フォーカスを再生ボタンに戻しているだけです。ここも本当は Esc キーで同様の処理をしたかったのですが、残念ながら簡単に実現できる方法が Flet には無かったので諦めました。

対象のコードを開く
# When timestamp clicked calls AudioSubPlayer.sub_time_clicked to jump to button position.
async def jump_clicked(self, e):
    await self.sub_time_clicked(self.start_time)

タイムスタンプがクリックされたときのジャンプ async def jump_clicked(self, e)

182-183行では、タイムスタンプボタンがクリックされたときにそこから再生するよう、字幕の開始時間start_timeを親クラスのsub_time_clickedに渡す処理をしています。Audioコントロールのseekメソッドを実行するため、このクラスの中では唯一awaitが必要となっています。

Flet (GUI) とは直接関係ない関数

本アプリでは必要ですが、Flet のコードに組み込む必要が無かった処理は、コードの先頭部分にまとめてあります。ざっと説明すると、ミリ秒を SRT で使用する時間表示へ切り替える関数、その逆を行う関数、そして、読み込んだ字幕ファイル TXT もしくは SRT を、アプリ内で使いやすいリストに格納する関数が書かれています。

以上で、コードの直接的な説明を終わります。

作ってみて思ったことと、この記事を書いた理由

Flet は割と簡単にモダンなデザインでアプリが作れるので、そこが大きな魅力です。GUI の構成要素となるボタンやテキストはもちろん、音声ファイルの処理やスライダー、通知やダイヤログなど、あらかた必要なものはそろっており、細かいところは気にせずに配置していくだけで、とりあえずアプリっぽいものが作れてしまいます。持っていないので想像でしかないのですが、質の良い 3D プリンタを手に入れたのと近い気がしています。3D モデルデータが実物になるように、Python のコードが触れるようになるという感覚です。

オフィシャルのドキュメントは充実しており、うまく理解できれば自分のアプリに利用できます。基本的に全ての機能はウェブブラウザで動く設計なので、実際に触って試せる Live example も多数用意されていて、いじりながら自分のアプリに使う部品を探す作業も楽しいです。このギャラリーに行けば、おそらくほとんどのコントロールや機能のサンプルを試し、GitHub で実際のコードの確認が行えるので、すごく助かります。

ただし、Flet は遙か昔に流行ったオーサリングツールのような、マウスを操作して GUI を作るツールではないので、全ては Python コードで実装することになります。また、完成したアプリのインターフェイスはウェブのフロントエンド (HTML、JavaScript、CSS) で実装されます。なので、必須ではありませんが、これらの知識を持っておくことが完成品を作る近道だと思いました。フロントエンドをしっかり学習するつもりの無い人が Flet の中心的利用者だと想像していますが、それでもそれぞれの入門書を 1~2冊読んでおくと、Flet のドキュメントを読んだときにすっと理解できると思います。

Flet に関する日本語の情報は増えてきているようですが、紹介ブログ記事を書くために「さわってみた」というものが大半で、自分がアプリを作るのに有効だった記事はあまり見つけられませんでした。オフィシャルのドキュメントも一通り網羅されているものの、実際に使うとなると思ったほど簡単ではありませんでした。なので、本アプリ字幕極楽丸が完成に近づいた頃に考えていたのは、Flet アプリの作り方を記事としてまとめることでした。絶対に必要な人がいる、という思いで、アプリも記事も (適当なところや冗長なところがありつつも) 完成できてよかったと思っています。

Python としてのロジックは、結構 Copilot (無料版) に相談して書いてもらいました。Flet のことはあまり知らないようですが、Python そのものに関してはかなり頼りにできることがわかりました。自分でデバッグまでしてしまうコード生成 AI も出てきているので、アイディアがあればアプリ作成のハードルはすごく下がってきてますね。

簡単なものでも、自分で考えたアプリが完成すると感動します。ビルドすれば、他の人にも触ってもらえます。ぜひ、Flet でのアプリ開発をはじめてみてください。

Image by Stable Diffusion

使ったモデルのせいもあり、白人のおじさんおにいさんばかりが生成されたので、プロンプトに入れてあった “realistic, masterpiece, best quality” を “cartoon” に変更。いくつか描いてもらった中で、ほとんどの指示を無視して生成されたイラストを今回のアイキャッチに採用。生成物の落差がすごい。

Date:
2024年4月8日 19:16:06

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
cartoon, retro future, guy partially gray hair with glasses, white t-shirt, typing keyboard, happy coding!

Exclude from Image:
frame, old, fat, suit

Seed:
2776787021

Steps:
50

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

Flet で Audio コントロール付きアプリのビルドに成功

前回アップした音声+SRT 同時再生・テキスト編集アプリ『字幕極楽丸』ですが、NumPy を不使用にすることで macOS アプリとしてビルドできるようになりました。ただ、過去の記事紹介したテンプレートを使ってコピーライト等を追加するビルド方法だとエラーで失敗します。Audio コントロールを使っているとうまくいかない様です。その後試行錯誤の末、成功ビルドパターンがわかったのでご紹介します。

念のため環境はこちら

  • Hardware: Mac Studio M2 Max 12-core CPU 30-core GPU 32GB RAM
  • OS: macOS Sonoma 14.3.1
  • Xcode: 15.3
  • Python 3.11.7
  • Flet: 0.21.2
  • Flutter 3.19.3
  • Dart: 3.3.1
  • CocoaPods: 1.15.2

まずは NumPy を使わないように変更

以前公開したコードでは、音声の再生位置に応じてスクロールさせるため、該当するテキスト位置を算出する事だけに NumPy を使用していました。具体的には、以下の 520 行目になります。これを 522行目に変更することで、NumPy の import を不要にしました (GitHub 反映済み)。3行目のインポート部分もコメントアウト(# import numpy as np) もしくは削除します。これでとりあえず flet build macos --include-packages flet_audio でアプリのビルドが確認できました。

    # Called when slider position is changed and scroll to subtitle with the nearest end_time.
    async def scroll_to(self, e):
        end_time = [item[2] for item in self.subtitles]
        # Numpy is only used below:
        #index = np.argmin(np.abs(np.array(end_time) - e))
        # Below works without using Numpy:
        index = min(range(len(end_time)), key=lambda i: abs(end_time[i]-e))
        key=str(self.subtitles[index][0])
        self.subs_view.scroll_to(key=key, duration =1000)
        self.update()

この変更による体感できるような遅延は発生していません。ガッツリ NumPy を使ったアプリをビルドしたい方は、Flet 自体の解決を待つか (Issue 報告済み)、flet pack main.py を使うかですかね (非推奨っぽいですけど)。

単純なビルドは成功、しかしテンプレートが使えない

次にコピーライト表記などを行った上でビルドしようと、テンプレートのクローンをしてファイルを編集して、でビルドを実施したところ、以下のエラーで失敗となりました。flet_audio のバージョン?依存関係?か何かの問題のようです。複数のバージョンが入り乱れて非常に難解です。

% flet build macos --build-version "1.0.1" --template flet-build-template --include-packag
es flet_audio
Creating Flutter bootstrap project...OK
Customizing app icons and splash images...OK
Generating app icons...Because flet_audio <0.20.1 depends on flet ^0.20.0 and flet_audio >=0.20.1 <0.20.2 depends on flet ^0.20.1, flet_audio <0.20.2 requires flet ^0.20.0.
And because flet_audio ^0.20.2 depends on flet ^0.20.2 and flet_audio >=0.21.0 <0.21.1 depends on flet ^0.21.0, flet_audio <0.21.1 requires flet ^0.20.0 or 
^0.21.0.
And because flet_audio >=0.21.1 <0.21.2 depends on flet ^0.21.1 and flet_audio >=0.21.2 depends on flet ^0.21.2, every version of flet_audio requires flet 
^0.20.0 or >=0.21.0 <0.22.0.
So, because fletaudioplayback depends on both flet ^0.19.0 and flet_audio any, version solving failed.


You can try the following suggestion to make the pubspec resolve:
* Try upgrading your constraint on flet: dart pub add flet:^0.21.2

Error building Flet app - see the log of failed command above.

Flet のバージョンを上げたり下げたり、Flutter やその他環境面でのバージョンを最新にしたりなどいろいろと試しましたが、最終的にはテンプレートファイルを --template で読み込むとエラーになることがわかりました。

【解決方法】テンプレートをやめてオプションを使う

どうやら Audio コントロールを使う場合はテンプレートを読み込めない?みたいなので、オプションを使用してコピーライト表記等を組み込むことで解決しました。スマートじゃ無いですが、いつかは解決すると思うので仕方なし。実際に使ったコマンドとオプションは以下となります。長いです。

flet build macos --build-version "1.0.1" --copyright "Copyright (c) 2024 Peddals.com" --product "地獄極楽丸" --include-packages flet_audio

軽く説明すると、--product に文字列を渡すとアプリ名にできます (指定しない場合は、親フォルダ名がアプリ名になります)。--include-packages flet_audio は Audio コントロールを使用する場合に必須です。あとは見たまんまです。使えるオプションのは確認こちらでどうぞ。

ちなみに、assets フォルダ以外のファイル・フォルダはビルドに関係が無いと思っていましたが、main.py と同じフォルダにあった音声ファイルなども取り込まれたようで、最初にビルドした時は非常に時間がかかり、完成したアプリも 1.7GB ほどありました (削除後は 260MB 程度)。なので、ビルドの際は専用のフォルダを作ってそこに main.py とその他必要なファイルのみ格納した状態にするのが望ましいです。

アプリとして実行した後の動き

build/macos フォルダ内にビルドされたアプリをダブルクリックで開くと、初めての場合ボクの環境では画面が描画されるまでしばらく待たされました。よく見てみるとアプリ自体の問題というより、アンチウィルスソフトが CPU を 300% ほど使って懸命にスキャンをしていたのがその理由のようです。安全性が確認された後は一瞬で開くようになりました。音声ファイルの再生中 の CPU 使用率は、おおよそ 30~40%というところです (なぜか起動時のスプラッシュが無いので、何か問題があるのかもしれません。影響ないので深追いしてないですけど)。

おまけ (Bing Copilot にコードを直してもらったやり方)

冒頭で書いた NumPy 必須のコードを書き換えた、というところですが、実は自力では無く Bing の Copilot 無料版に手伝ってもらいました。字幕極楽丸を仕上げるまでにも Python のロジック部分に何度も手伝ってもらったのですが、やっとお願いの仕方に慣れてきた感じです。具体的な情報を伝えて、何度かやりとりすることを想定して挑むと、チャット上限の 5回でも望む結果が得られやすいですね。参考まで、実際のやりとりがこちらになります。

自分

python で numpy を使っているコードがあるのですが、numpy を使わないで実装できるよう手伝ってもらえますか?

Copilot

もちろん、お手伝いします。numpyを使っているPythonのコードを教えていただけますか?そのコードを見て、numpyを使わないバージョンを提案します。よろしければ、コードを共有してください。よろしくお願いします。🙂

自分

end_time がリストで、小さい値から大きな値までの整数を持っています。e は整数です。import numpy as np でインポートしています。対象のコードはこちらです: index = np.argmin(np.abs(np.array(end_time) – e))

Copilot

numpyを使わないで同じ処理を行うPythonのコードは以下の通りです:

index = min(range(len(end_time)), key=lambda i: abs(end_time[i]-e))

AI で生成されたコード。ご確認のうえ、慎重にご使用ください。FAQ で詳細を確認

このコードは、end_timeリストの各要素とeとの絶対値の差が最小となるインデックスを見つけます。これは、元のnumpyのコードと同じ動作をします。ただし、numpyを使わないため、大きなリストに対しては実行速度が遅くなる可能性があります。ご了承ください。🙂

Image by Stable Diffusion

まあいいんですが、当初 gray hair (と glasses もかな?) に引っ張られて、恰幅の良いおじさんばかりが生成されました。jumping も無視されがち。Exclude に不要な要素をいくつか加えたらそれっぽくなりました。人物が遠いと顔面が破綻しがちで、ステップを増やしてもあんまり関係ないですね。

Date:
2024年3月31日 1:55:34

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
realistic, masterpiece, best quality, retro future, happy young guy partially gray hair with glasses jumping with big simle in front of a beautiful building

Exclude from Image:
frame, old, fat, suit

Seed:
1847693693

Steps:
50

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

Flet で作った、音声と字幕 (SRT) の同時再生・字幕編集アプリ『字幕極楽丸』のご紹介

OpenAI の音声文字起こし AI である Whisper を試したところ、その性能の高さに非常に興奮しました。無料なうえローカルで完結し、M1 mac mini 16GB RAM なら音声ファイル自体の 90% 位の時間でかなり使える文字起こしをしてくれます。すごい時代です。で、精度を高める方法を調べていろいろ試したものの、やはり 100% にはなりません。だったら逆に、人間がテキストを簡単に修正ができるアプリがあれば良いだろうとの発想から、音声を聞きながら Whisper が書き出した文章を編集できるアプリ、その名も『字幕極楽丸』を Flet で作りました。今回はとりあえずアプリの紹介として、使い方やインストール方法を説明します。コードの内容に関しては別記事にするつもりですが、(適当な英語の) コメントを入れてあるので、気になる方は GitHub を覗いてみてください。

アプリの紹介

Whisper 等の文字起こし (speech-to-text) ツールで書き出した字幕ファイルを、音声ファイルと同期しながら再生し、必要に応じてテキストを編集できるというのがこのアプリです (と言っても、現状は flet build macos コマンドで実行ファイルとして書き出すと Numpy の読み込みができないためにクラッシュします。←解決済み。また、いくつか既知の不具合もあるため、一応の完成型といういう状態で公開してます)。英名は全くひねらず『Speech + Subtitles Player』、略して SPSP (または、SPS Player)。和名は『字幕極楽丸』です。はい、ファミカセ『地獄極楽丸』インスパイアー系の命名となっています (名前だけ)。ま、どうでも良い話ですね。ロゴにはカッコイイフリーフォント (フロップデザインさんの、源界明朝) を使わせていただきました。ありがとうございます。

アプリの使い方

(実行方法は下記「アプリの実行方法」参照) Open Speech File ボタンで音声ファイルを読み込むと、同名の字幕ファイル (拡張子 .srt) もしくはテキスト (拡張子 .txt) が同じフォルダにあれば自動的に読み込み、タイムスタンプと字幕テキストを表示します。Play ボタンで音声を再生し、テキストは音声に合わせてスクロールします。タイムスタンプをクリックすると、そこへ頭出しします。テキスト部分をクリックすると修正が行えます。編集内容は Save ボタンで同一ファイルに上書きし、SRT と TXT ボタンはそれぞれ別ファイルとして書き出します。1.5x と Auto scroll のスイッチはそれぞれ、1.5 倍速再生、音声に合わせたテキストの自動スクロールをオン・オフできます (自動スクロールはタイムスタンプのある SRT 形式のみ可能)。現在既知の問題として、Open/Export as のボタンをクリックするとダイアログが開かず、アプリ全体が動作しなくなることが確認できています。編集を行っている際には頻繁に Save をクリックするようにしてください。

TXT で書き出した場合はタイムスタンプが含まれない文章のみのため、アプリ内でのオートスクロールができませんが、議事録やレポートなど本アプリ以外の様々な用途に利用できます。SRT は字幕フォーマットとしてよく使われる形式 (Wikipedia) で、音声の元データが映像であれば、DaVinci Resolve (フリーでも利用できる映像編集アプリ) 等で字幕データとして動画に取り込めます。

アプリの対象ユーザ/ユースケース

映像に字幕を埋め込みたい方が主な対象ユーザになると思います。また、Whisper 含め文字起こし AI の精度の検証を行うエンジニアや、コールセンタで通話内容をレポートにまとめるオペレータと言った方々には有用だと思います。他には、ミーティングの議事録を AI に出力させてから清書をするとか、外国語の学習にも便利に使ってもらえるでしょう (精度の違いを無視すれば、Whisper ではかなりの数の言語がサポートされています: Supported languages)。

アプリの実行方法

アプリアプリ言ってますが、現状ダブルクリックで開くアプリケーションとして書き出せないため、下記方法でコマンドラインからの実行が推奨です。無事アプリにビルドできたので、別記事にしました。テストおよびビルドは macOS のみで行っています。大きな違いは無いはずですが、Windows や Linux の方は、すみませんがよしなにお願いします。コードは GitHub に置いてあります。

https://github.com/tokyohandsome/Speech-plus-Subtitles-Player

コードをクローンし、Python の仮想環境を作り、Flet と Numpy をインストール

Python は 3.8 以降であれば大丈夫のハズです (制作環境の Python は 3.11.7)。以下例では仮想環境の作成に pipenv を使用していますが、何でもかまいません。

git clone https://github.com/tokyohandsome/Speech-plus-Subtitles-Player.git
cd Speech-plus-Subtitles-Player
pipenv --python 3.11
pipenv shell
pip install flet
pip install numpy

アプリを実行

環境ができたら、以下コマンドで字幕極楽丸を実行できます。ダブルクリックで開くアプリをビルドする方法は別記事にしました。

python main.py

音声ファイルを選択

起動後、Open Speech File ボタンをクリックして、MP3 や WAV 等の音声ファイルを選択します。macOS では初回に書類フォルダへのアクセス権を与えるか聞かれると思いますので、許可してあげてください。音声ファイルと同じフォルダに、同じファイル名で拡張子が .srt (もしくは .txt) となっているファイルがあると、自動的に読み込まれます (スクショで言うところの、kishida.m4a を開くと kishida.srt も読み込まれる)。音声ファイルを読み込んだ後ならば、手動で字幕ファイルを読み込むこともできます。

既知の不具合

クリティカルな問題は無いと思いますが、心配な方は SRT や TXT のバックアップを別の場所に保存した上でご利用ください。

  • 字幕のボタンが多くなると、ウィンドウの移動やリサイズの際にカクつきます
  • flet build macos --include-packages flet_audio 等としてアプリケーションとして書き出しても、実行時にクラッシュします  (Flet version == 0.21.2)。オートスクロールが不要であれば、import numpy as np をコメントしてもらえれば、書き出した実行ファイルも動きますNumpy を不使用にすることでビルドできるようにしました
  • Open や Export のボタンをクリックすると、ダイアログが開かず、アプリを閉じる以外できなくなることがあります (原因調査中)。セーブは頻繁に行ってください
  • macOS で再生できる MP3 のサンプルレートは 44.1KHz までのようです。それより高いサンプルレートの場合は Audacity 等で変換してください
  • SRT は本来、字幕部分が複数行あっても良いみたいですが、本アプリでは 2行以上あることを想定していません。Whisper で書き出した SRT ファイルは問題無いはずです

おまけ

細かいことは書きませんが、Youtube 等のオンライン動画を音声ファイルとしてダウンロードする方法と、Apple が Apple Sillicon 用に最適化した Whisper で音声ファイルを SRT に書き出す方法を貼っておきます。

オンラインの動画を m4a 音声ファイルとしてダウンロード

pip install yt_dlp
python -m yt_dlp -f 140 "動画ページのリンク"

Whisper で音声ファイルを SRT に書き出す (文字起こしする) Python スクリプト

macOS であれば、MLX 版 Whisper が動く環境を作り、Hugging Face から whisper-large-v3-mlx (jsonnpz) をダウンロードして mlx_models フォルダに展開します。その後、mlx-examples/whisper フォルダに以下のファイル speech2srt.py を作ります。5-6行目のフォルダ名とファイル名はそれぞれ書き換えてください。日本語以外の場合は、音声の言語に合わせて language='ja', の部分を変更してください (日本語の音声を ‘en’ で指定すると英語訳した字幕が書き出され、英語音声を ‘ja’ 指定すると日本語訳されたものが書き出されます。が、残念ながら翻訳の精度は非常に低いのでやめた方が良いでしょう)。

import whisper
import time
import os

base_dir = "音声ファイルのあるフォルダ名"
speech_file_name = "読み込みたい音声ファイル名"

start_time = time.time()
speech_file = base_dir + speech_file_name
model = "mlx_models/whisper-large-v3-mlx" 

result = whisper.transcribe(
                            speech_file, 
                            language='ja', 
                            #language='en', 
                            path_or_hf_repo=model, 
                            verbose=True,
                            #fp16=True,
                            word_timestamps=True,
                            condition_on_previous_text=False,
                            #response_format='srt',
                            append_punctuations="\"'.。,,!!??::”)]}、",
                            #append_punctuations="。!?、",
                            #initial_prompt='です。ます。した。',
                            temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0),
                            )

end_time = time.time()
elapsed_time = round(end_time - start_time, 1)

print('############################')
print(f"処理にかかった時間: {elapsed_time}秒")
print('############################')

def ms_to_srt_time(milliseconds):
    seconds = int(milliseconds / 1000)
    h = seconds // 3600
    m = (seconds - h * 3600) // 60
    s = seconds - h * 3600 - m * 60
    n = round(milliseconds % 1000)
    return f"{h:02}:{m:02}:{s:02},{n:03}"

subs = []
sub = []
for i in range(len(result["segments"])):
    start_time = ms_to_srt_time(result["segments"][i]["start"]*1000)
    end_time = ms_to_srt_time(result["segments"][i]["end"]*1000)
    text = result["segments"][i]["text"]

    sub = [str(i+1), start_time+' --> '+end_time, text+'\n']
    subs.append(sub)

text_file = base_dir + os.path.splitext(os.path.basename(speech_file_name))[0] + ".srt"

# Overwrites file if exists.
with open(text_file, 'w') as txt:
    for i in subs:
        for j in range(len(i)):
            txt.write('%s\n' % i[j])

後は Python で実行すれば、音声ファイルと同じフォルダに SRT ファイルが作られます。同名ファイルがあると上書きするので注意してください。

python speech2srt.py

Whisper は GPU で動きます。参考まで、Mac Studio (M2 Max 30 コア GPU) だと、大体音声の長さの 1/6 位で書き出しが完了します。

Image by Stable Diffusion

文字起こしをするために音声に集中している女性達それぞれの表情の違いが良いですね。指が怖いですが、ステップ数をいじってもこれ以上良いバランスの絵は生成できませんでした。

Date:
2024年3月24日 23:45:42

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
realistic, masterpiece, best quality, retro future, office ladies transcribing audio from record player

Exclude from Image:

Seed:
2389164678

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

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

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

2023年末にリリースされた Flet のバージョン 0.18.0 では、以前からあった pack より良いビルド方法 build が導入されました。自分も macOS 用にビルドしてみたのですが、なぜか一つの環境では Hello, Flet とだけウィンドウに表示されるアプリがビルドされ、いろいろ試すも解決できず。そしてつい数日前の 2024/1/16 に、主に flet build 関連の bug fix がなされたバージョン 0.19.0 がリリースされたので、macOS 用デスクトップアプリをビルドしてみました (flet 0.21.2 で Audio コントロールを使用する場合のビルド方法は別記事にまとめました)。

リリースのあった公式 discord: https://discord.com/channels/981374556059086931/981375816489402408

公式 GitHub の change log: https://github.com/flet-dev/flet/blob/main/CHANGELOG.md

公式 flet build の解説ページ (英語): https://flet.dev/docs/guides/python/packaging-app-for-distribution

まずはビルド前の Python スクリプトを簡単にご紹介

パスワード生成アプリを作りました。今回は Flet のビルドの紹介をメインとするので、アプリ自体の細かい説明は省きます。が、簡単に何ができるかというと、好きな文字数で、好きな特殊文字を含んだ、ある程度強度のあるパスワードを生成することができます。生成されたパスワードはコピーボタンでクリップボードにコピーできます。Safari が作ってくれる強力なパスワードが使えないサイトや、もっと強度を強くしたい、等の用途で活躍します。パスワード強度をチェックしてくれるサイト (bitwarden 等) で強度を調べると解析されるまで数世紀かかる等と結果が出て面白いです。

コードは GitHub に置いてあります: https://github.com/tokyohandsome/passgen.py/blob/main/fletpassgen.py

今回の Flet アプリ。Generate で生成し、Copy でクリップボードにコピー。文字数と特殊文字はキーボードから入力可能

環境

  • macOS: 14.2.1
  • 仮想環境 (pipenv): 2023.10.24
  • Python: 3.11.6
  • 追加モジュール: flet (バージョン: 0.19.0) 自動的に flet-core と flet-runtime も入っている
  • Rosetta
  • Xcode: 15.2
  • Git: 2.29.2
  • cocoapods: 1.14.3_1
  • Flutter: 3.16.7

手順

  1. 仮想環境を作る
  2. flet をインストール (バージョン 0.18.0 以上、今回は最新の 0.19.0)
  3. コードを書く、もしくは持ってくる
  4. 動作確認
  5. Flutter とその他必要なものを一式インストール
  6. アプリ名のフォルダを作り、以降はその中で作業
  7. assets フォルダを作り、アイコン (icon.png) を格納
  8. 手順 3. のコードを main.py とリネームしてコピー
  9. main.py の最後、メイン関数を呼び出す行を ft.app(main) だけにする
  10. requirements.txt に追加モジュールを書く
  11. ビルドテンプレートを GitHub からクローン
  12. ビルドテンプレートのコピーライト表記などを編集
  13. ビルド実行

部分的に細かく (手順 1~4)

仮想環境はお好きなのを使って良いと思います。Python は 3.8 以降、flet は特にバージョン指定せず pip install flet で良いでしょう。自分で書いた Flet アプリのコードが無ければ、上記のボクの GitHub からコピーしたものを適当な名前 (例: fletpassgen.py) で保存してください。python3 fletpassgen.py で動作することを確認したら、デスクトップアプリビルドに必要な諸々をインストールします。

Flutter とその他必要なものを一式インストール (手順 5)

ビルドには元祖である Flutter や開発言語の Dart とその他必要なもの一式をインストールする必要があります。基本的には一度済ませれば何度も行う必要はありません。以下は Apple Silicon (M1、M2、M3 シリーズ) の場合です。Flutter のサイトを参考に全て準備しましょう。

1. Rosetta: 以下を実行してインストール

sudo softwareupdate --install-rosetta --agree-to-license

2. Xcode 15: このページ右上の Download から Xcode 15 をダウンロードし、インストール

3. Cocoapods: 以下を実行してインストール

brew install cocoapods

4. Git: 以下を実行してインストール

brew install git

5. Flutter: ページ中頃のあたりの手順に従い、自分の CPU 用の Flutter SDK をダウンロードし、適当なフォルダ (例では ~/development/) に移動して .zip ファイルを展開、最後に環境変数 PATH にパスを追加 (以下の handsome を自分のユーザ名に変えて、~/.zshrc に追加し、source ~/.zshrc で読み込み)

export PATH="/Users/handsome/development/Flutter/flutter/bin:$PATH"

ビルドの準備 (手順 6~12)

Flet アプリとしての動作が確認できたら、最終的なアプリ名のフォルダを作り、中に入って準備を進めます。今回は fletpassgen という名前のアプリにします。

mkdir fletpassgen
cd fletpassgen
mkdir assets
open assets

最後の open assets で Finder でフォルダを開くので、アプリのアイコンに使用したい 512×512 ピクセルの画像ファイルを icon.png に従った名前で保存します (フォーマットは他に .bmp.jpg.webp が対応)。今回は使いませんが、その他アプリで使用する音声やテキストなどのファイルも assets フォルダに保存します。

アイコン画像について余談: 画像ファイルが無くても Flet のアイコンが使われるので、ビルドはできます。ボクは画像生成 AI 、Stable Diffusion の mac 用クライアントアプリ Mochi Diffusion を使ってアイコンを生成しました。参考までに本記事の最後にモデルやプロンプトを貼っておきます。

次に、Flet アプリのコードを main.py としてコピーしてきます。

cp ../fletpassgen.py main.py

Python コード最後の main 関数呼び出し部分が ft.app(main) じゃない場合は変更します。

#if __name__ == "__main__":ft.app(target=main) だったりしたら変更
ft.app(main)

requirements.txt には追加が必要なモジュールを記載するのですが、pip freeze > requirements.txt で書き出すと様々なエラーでビルドが止まり、ハマりました。今回のように標準のモジュールしか使用していない場合は、flet だけあれば大丈夫です。iOS や Android 用にビルドする場合も注意が必要なので、詳細は公式ページを見てください。

flet

コマンド + i でコピーライト表記が正しく表示されるように、テンプレートを公式の GitHub からクローンして cookiecutter.json の中身を書き換えます。以下例ではエディタに vi (vim) を使用していますが、お好みでどうぞ。

git clone https://github.com/flet-dev/flet-build-template
vi flet-build-template/cookiecutter.json

ハイライトした、7-9行目だけいじってます。

{
    "out_dir": "",
    "python_module_name": "main",
    "project_name": "",
    "project_description": "",
    "product_name": "{{ cookiecutter.project_name }}",
    "org_name": "com.peddals",
    "company_name": "Peddals.com",
    "copyright": "Copyright (c) 2024 Peddals.com",
    "sep": "/",
    "kotlin_dir": "{{ cookiecutter.org_name.replace('.', cookiecutter.sep) }}{{ cookiecutter.sep }}{{ cookiecutter.project_name }}{{ cookiecutter.sep }}",
    "hide_loading_animation": true,
    "team_id": "",
    "base_url": "/",
    "route_url_strategy": "path",
    "web_renderer": "canvaskit",
    "use_color_emoji": "false"
}

ビルド実行 (手順 13)

何もかも気にせずとりあえずビルドするなら、flet build macos でかまいません。バージョンを指定し、テンプレートをローカルに作ったものから読み込ませるなら、もうちょっと長く以下のように実行します (バージョン --build-version は指定しなければ 1.0.0 になるので、このサンプルではあえて 1.0.1 にしています)。(以下、Flet 作者の Feodor による説明を元に訂正 2024/01/25) ビルドテンプレートの指定は公式の説明に理解が及ばず、当初以下のように書いていましたが、--template の後に相対パス指定になります。修正前の方法でもビルドできますが、あるべき書き方にコマンドも修正しました。テンプレートの指定は相対パス (relative path) と公式ドキュメントにありますがうまくいかないので `pwd` を頭に付けて絶対パスとして指定しています。また、テンプレート指定のオプション自体が公式だと --template_dir (アンダースコア) になっていますが、0.18.0 と 0.19.0 では --template-dir (ハイフン) が正解でした。

flet build macos --build-version "1.0.1" --template flet-build-template

やや待って Success! が表示されれば完了です。build/macos/ の下に、アプリ fletpassgen.app が作られています。ボクの書いた fletpassgen.py を M1 mac mini でビルドすると、大体 3分 10秒くらいで完了しました。基本的に高効率コアが 6-8 割の使用量で推移し、所々パフォーマンス (高性能) コアのスパイクが見える感じです (他にもアプリが動いているので、はっきりとしたことはわかりませんけど)。

Creating Flutter bootstrap project...OK
Customizing app icons and splash images...OK
Generating app icons...OK
Packaging Python app...OK
Building macOS bundle...OK
Copying build to build/macos directory...OK
Success!
カラーだとこんな感じ

完成したアプリは Universal

アイコンはもちろん、バージョンとコピーライト表記も反映されている

ビルドされたアプリはアプリケーションフォルダにコピーしてダブルクリックで開けます。プライバシーとセキュリティで実行許可を与える必要はありません。Universal バイナリなので、試していませんが Intel mac でも動きそうです。アプリにした後の動作自体は CLI から実行したときと変わりありません。ウィンドウが開くときに若干もたつきがあり、Flet デフォルトのウィンドウが開いてから、指定したウィンドウにリサイズしてコンテンツを表示する、という動きも同じです。自分のコードの最適化か、Flet のアップデートで解決するかもしれません。

感想

実に良いです。これまで tkinter や、そのラッパー pysimplegui を使って GUI アプリを幾つか作りましたが、Flet は断然作りやすく、デザインはモダンで、今回紹介したようにビルドも簡単です。今後試したいこととしては、mac で Windows 用にビルドはできないようなのでParallels を使用したビルドを試したり、iOS やウェブ (サーバサイドでは無く、スタティック) 用にビルドすることも試したいと思います。一気にやれることが増え、やりたいことも増えてすごく楽しいですね。mac をお持ちの方は、ぜひお試しあれ (もちろん Windows や Linux の方も)。

Image by Stable Diffusion

Date:
2024年1月15日 23:05:04

Model:
realisticVision-v20_split-einsum

Size:
512 x 512

Include in Image:
masterpiece, best quality, retro future, cyber, disco computer, password generator

Exclude from Image:

Seed:
3224310018

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & Neural Engine

© Peddals.com