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

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

Contents

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

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

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

© Peddals.com