日本語 TTS の最高峰 Style-Bert-VITS2 の学習を mac で実行する

注意!今のところ、macOS での学習は CPU 専用です。

高性能日本語 text-t-speech である Style-Bert-VITS2 ですが、作者様が公開されているバージョンで学習するには NVIDA GPU が必要です。悔しいのでなんとかならないものかとコードをいじっていたら、mac でも学習ができるようになりました。公開時点では、まだ CPU のほんの少しの力しか使えないので非常に遅く、6秒の音声の学習に M2 Max 12 コア CPU で 7分 20秒 5分 11秒かかる状態です。とは言えこれまで macOS では既存モデルを使った音声合成しかできなかったわけですから、個人的には大きな前進だと思っています (試してないですが、他の OS でも動くかもしれません。NVIDIA GPU を持っていない、Windows や Linux の方はお試しの上、結果を教えてください)。

というわけで、とりあえず公開します。

コードと詳細は GitHub へどうぞ

インストール方法と固有の使い方は GitHub でご確認ください。

GitHub: https://github.com/tokyohandsome/Style-Bert-VITS2-Mac/tree/master

PyTorch を知らぬ男がどうやったのか

基本は気合い、そして諦めない気持ち。計算するデバイスをcudaからmpsにする程度でしょ、と思っていたら全然うまくいかず、まずはcpuでとにかく動かそう、と方針転換。エラーが出ては PyTorch 2.3 の公式ドキュメントを読み、Deepseek Coder V2 の力も借りながら一つ一つ潰していく作業。時に自分のひらめきに助けられ、二晩後。無事にトレーニングが終わり、モデル一覧に追加された音声で聞いた「こんにちは、初めまして。あなたの名前はなんていうの?」の美しさたるや。プログレスバーが動き出してから完了するまでに数分待たされたのも、今思えば粋な演出でしたね。ま、ともあれ話を戻すと、コードで実行される処理自体は複雑では無く基本的に上から下へ、なので、一つ一つ CPU で処理するようにしていっただけ、ということになります。

これからどうするか

MPS で動かすのはまだいくつもハードルがありそうなので、CPU をバンバン使って高速化できないのかな、と考えています → 使用できるコア数の半分を使用するようにし、少し高速化しました (v1.0.1)。Whisper を macOS で動かすのは多分 MLX 版が良い (しかない?) と思うのでそこをどうにかしたり、複数の音声ファイルがあればesd.listを自動生成するくらいはできそうですよね。スタイルまで手を出すかどうかはまだわかりません。とりあえずもっと学習を高速化して、モデルの品質を高める作業時間を減らしたいです。

おや?いつの間にか Whisper は pip で入れられる時代なんですね: mlx-whisper

Image by Stable Diffusion

Web UI にいる女子キャラや日本語が得意ということから、日本人の女の子をいくつか描かせたのですが全くイメージと違い、manga 調で競争している女子を描かせても顔が破綻していて多方面から怒られそうだったので、アプローチを全く変えました。深い森からやっと抜け出せた喜びと今の季節感を盛り込んだ、情緒的なトップ絵です。

Date:
2024年10月4日 23:12:07

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
photo realistic beautiful nature in the late summer. fresh air and sunshine

Exclude from Image:

Seed:
3212833797

Steps:
23

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Llama3.1 軽量版 8B の日本語能力を高める方法 (Dify の DSL あります)

ローカル LLM で満足のいくレベルの日本語を使うことは大きなチャレンジであり、研究機関や企業、高校生などによって、日々研究開発が行われています。海外の大手企業などが新たな LLM をリリースするたびに、日本語に対応していると正式に謳っているか、実際に使い物になるか、等という情報が LLM 界隈に行き交い、様々なテストが行われます (X とかの SNS をやってないので想像含む)。同じ質問を投げかけてどのような回答があるか、日本語は正しいか、英語や中国語が混じっていないか、回答は正しいか、内容は充実しているか、というあたりはブログなどの結果を見ると判断できますが、実際に使って思うのは、文法や言葉遣いは二の次で、本当に欲しいのは内容の充実度とその応答速度である、というところです。また、長文を翻訳させたいとか、LLM を切り替えるときの読み込み時間がイヤということもあり、ボクがここしばらくチャットで使っているローカル LLM は、llama3.1:8b-instruct-fp16 一本です。実サイズ 16GB なので、32GB しかメモリがない Mac でコンテキスト長を 3万トークンにしても高速で動いてくれます。

日本語能力の高い LLM を調べる

さて、LLM の日本語能力を機械的に多角的に評価され、信頼性が高そうなサイトに「Nejumi LLMリーダーボード3」さん (リンクは下記) があります。更新頻度は高く、商用/オープン・パラメータ数・リリースのタイミング・instruct/chat/text 等のバージョン違いを含めた非常に多くの LLM を評価されているようで、細かい内容まではわかりませんが、とにかく信頼してよさそうな情報量です。

リンク: https://wandb.ai/wandb-japan/llm-leaderboard3/reports/Nejumi-LLM-3–Vmlldzo3OTg2NjM2?accessToken=wpnwc9whr96pxm40dfe4k3xq513f9jc4yhj7q6pnvj4jtayoefbc77qhzbsrztgz

こちらの Nejumi さんでの Llama3.1 の順位はというと、2024/09/28 現在 8位で、オープンなモデルでは Alibaba 社の Qwen 2.5 に次いで 2番目です。すばらしいですね。ただし、パラメータ数は Llama3.1 最上位の 405B なので、ボクの持っている 32GB RAM の Mac では動かせません。8B モデルの順位はというと、次のページの真ん中よりちょっと上あたりにやっと登場。他の選択肢の多さを考えたら選ぶ理由は無い、というレベルです。

でも、個人の感想ですが、実際に使っているとそんなに大きな問題は感じません。元々学習に使ったソースの質が高かったのでしょう、405B を 8B に蒸留してもある程度の質が高い状態で保たれていると感じます。Dify でコンテキスト長を 32,768 トークンにしてもトータルで 22GB に収まるため、32GB RAM でも処理は 100% GPU で行えます (つまり速い。14TPS 以上)。問題となるのは単純な知識の量ですので、必要なら英語で質問すればより多い知識から回答が得られます。そもそも対応言語に日本語が無いんですから、日本語でチャットができても、日本語での知識は乏しくても仕方ないんです。

HuggingFace: https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct

結論、英語で推論してもらえば良い

前置きが長くなりました。というわけで、この記事で紹介するのは、Llama3.1 に英語で推論して日本語で回答してもらう方法です。知識は英語で蓄えているのですから、知識を引き出すところだけ英語にしてもらえば、日本語の回答の精度や内容の質が高まるはずです。他の日本語対応を正式には謳っていない LLM でも同様の手法は有効だと思います。下記する方法を試したところ、プロトタイプ二つを経て、最終的には極めて簡単なプロンプトでほぼ期待したような結果が得られるようになりました。むしろ、20B 以下の少ないパラメータ数の LLM の場合、多言語対応だと浅く広くの知識にならざるを得ないでしょうから、「日本語での会話」がそれなりにでき、知識は英語やらフランス語やらの限られた言語で多く・深く収集された LLM の方が利用価値は高いと思います。

紹介する方法の使い方

Mac で Dify と Ollama で作ったので、同環境でしかテストしていません。ただ、最適化バージョンと、見える化バージョンは、実質システムプロンプトのみなので、LM Studio 等の System Prompt を設定できる AI ツールを使っても簡単に利用できると思います。Ollama 単体でも template を書き換えるか、毎回質問するときに記入すれば同様のことは実現できるかもしれません。

以下に Dify 用 DSL と System プロンプトを貼っておきます。Dify なら適当な名前.ymlとしてファイルに保存してから「DSL をインポート」で読み込んでください。LM Studio 等のツールで試すなら、DSL の後に貼ってある System prompt の内容をコピペしてください。LLM にはモデルプロバイダ Ollama でダウンロードした llama3.1:8b-instruct-fp16 を使用しています。Dify で別のものを使用する際には適宜変更してください (使用する LLM によっては期待した効果は得られない可能性があります)。

効果を見るには、System prompt を何も指定していない (デフォルト) の状態と、最適化したものを使った場合とで出力を比較してみてください。日本人なら当然知っているであろうこと (「漫画家鳥山明の代表作は」等) も日本語で聞くとハルシネーションを起こしがちですが、英語で推論されると正しい答えが得られるケースが多いです。また、海外から日本を見た第三者的解釈による回答が得られやすいのも良い側面だと思います。対して難点としては、英訳・和訳の処理が挟まるため微妙なニュアンスが変わってしまったり、固有名詞が間違った漢字やローマ時表記になることがあります。

まずは評価を簡単にしたかったので、回答内容に大きなブレや遊びが出ないように、Temperature と Top_P のそれぞれを 0.2 にしています。もっと厳密に評価・比較する場合は数値を下げたり、実際に運用フェーズで多様な生成を行いたいという場合には 1に近い値を採用するなど、目的に応じて工夫して使ってください。

また、Size of context window は 32GB RAM Mac で最大になるように32768にしてしまっています。GPU 使用率がマックスにならない場合はこの値が大きすぎる可能性があるので、チェックを外すなり小さな値にするなり、使用する LLM やご自身の RAM サイズに合わせて調整してください。参考:

日本語能力を高めたチャットボットの紹介

適当な名前.ymlとして保存した DSL を Dify の「DSL ファイルをインポート」でインポートすると現れるチャットボット

最適化バージョン

最終バージョンです。日本語で質問すると、内部的に英語で推論し、日本語で回答してくれます。一行のシステムプロンプトのみなので、レスポンスタイムに影響はありません。

★ Dify 用 DSL (クリックして表示する) ★:
app:
  description: Llama3.1 の持つ豊富な英語の知識を日本語で回答してくれる。日本語の固有名詞の変換が苦手なところは、日本語ペラペラなアメリカ人インテリも漢字はニガテ、みたいでかわいい
  icon: male-student
  icon_background: '#FFE4E8'
  mode: advanced-chat
  name: Llama3.1 最適化 (脳内処理バージョン)
  use_icon_as_answer_icon: true
kind: app
version: 0.1.1
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
    opening_statement: ''
    retriever_resource:
      enabled: false
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: true
    text_to_speech:
      enabled: false
      language: ''
      voice: ''
  graph:
    edges:
    - data:
        sourceType: start
        targetType: llm
      id: 1727272665783-llm
      source: '1727272665783'
      sourceHandle: source
      target: llm
      targetHandle: target
      type: custom
    - data:
        sourceType: llm
        targetType: answer
      id: llm-answer
      source: llm
      sourceHandle: source
      target: answer
      targetHandle: target
      type: custom
    nodes:
    - data:
        desc: ''
        selected: false
        title: 開始
        type: start
        variables: []
      height: 54
      id: '1727272665783'
      position:
        x: 80
        y: 282
      positionAbsolute:
        x: 80
        y: 282
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        memory:
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 10
        model:
          completion_params:
            keep_alive: 30m
            num_ctx: 32768
            temperature: 0.2
            top_p: 0.2
          mode: chat
          name: llama3.1:8b-instruct-fp16
          provider: ollama
        prompt_template:
        - id: d80ef4de-35f3-4106-87af-ff57023b2649
          role: system
          text: Infer question in English, generate rich answer, and output in the
            language used to ask.
        selected: true
        title: LLM
        type: llm
        variables: []
        vision:
          enabled: false
      height: 98
      id: llm
      position:
        x: 379
        y: 282
      positionAbsolute:
        x: 379
        y: 282
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        answer: '{{#llm.text#}}'
        desc: ''
        selected: false
        title: 回答
        type: answer
        variables: []
      height: 107
      id: answer
      position:
        x: 680
        y: 282
      positionAbsolute:
        x: 680
        y: 282
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: -111
      y: 197
      zoom: 1

System prompt (LM Studio 等で使用する場合はこちらをどうぞ):

Infer question in English, generate rich answer, and output in the language used to ask.

見える化バージョン

質問の英訳、英語での推論、最終的な和訳、の全てが表示されるバージョンです。それぞれの処理は一つの LLM ブロックで実施しています。上の最終バージョンでは本当に英語で推論したのかわかりませんが、こちらは途中経過もはっきり見て取れます。英語の勉強にも使えるかもしれません。

★ Dify 用 DSL (クリックして表示する) ★:
app:
  description: Llama3.1 が、質問を英訳し、推論し、日本語で返す、全てのプロセスを透明化したバージョン。冗長だが、英語の知識を使って回答していることがわかる。英語の勉強にもなるかも?
  icon: two_men_holding_hands
  icon_background: '#E4FBCC'
  mode: advanced-chat
  name: Llama3.1 英語で推論 (見える化バージョン)
  use_icon_as_answer_icon: true
kind: app
version: 0.1.1
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
    opening_statement: ''
    retriever_resource:
      enabled: false
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: true
    text_to_speech:
      enabled: false
      language: ''
      voice: ''
  graph:
    edges:
    - data:
        sourceType: start
        targetType: llm
      id: 1727270833994-llm
      source: '1727270833994'
      sourceHandle: source
      target: llm
      targetHandle: target
      type: custom
    - data:
        sourceType: llm
        targetType: answer
      id: llm-answer
      source: llm
      sourceHandle: source
      target: answer
      targetHandle: target
      type: custom
    nodes:
    - data:
        desc: ''
        selected: false
        title: 開始
        type: start
        variables: []
      height: 54
      id: '1727270833994'
      position:
        x: 80
        y: 282
      positionAbsolute:
        x: 80
        y: 282
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        memory:
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 10
        model:
          completion_params:
            keep_alive: 30m
            num_ctx: 32768
            temperature: 0.2
            top_p: 0.2
          mode: chat
          name: llama3.1:8b-instruct-fp16
          provider: ollama
        prompt_template:
        - id: 342e3642-d8b5-42c8-b003-7816a8ec7f3a
          role: system
          text: 'You are a skilled AI translator. Since your knowledge is best in
            English, translate any question into English for inference and generate
            answer. Follow the steps described below.


            ### Steps:

            1. You translate {{#sys.query#}}directly into English. Try maintaining
            the original format without omitting or adding any information.

            2. Generate response in English.

            3. Translate the response literary back into the language originally used
            by the user and output.'
        selected: true
        title: LLM
        type: llm
        variables: []
        vision:
          enabled: false
      height: 98
      id: llm
      position:
        x: 381
        y: 282
      positionAbsolute:
        x: 381
        y: 282
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        answer: '{{#llm.text#}}'
        desc: ''
        selected: false
        title: 回答
        type: answer
        variables: []
      height: 107
      id: answer
      position:
        x: 680
        y: 282
      positionAbsolute:
        x: 680
        y: 282
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: -205
      y: 134
      zoom: 1

System prompt (LM Studio 等で使用する場合はこちらをどうぞ):

You are a skilled AI translator. Since your knowledge is best in English, translate any question into English for inference and generate answer. Follow the steps described below.

### Steps:
1. You translate the query directly into English. Try maintaining the original format without omitting or adding any information.
2. Generate response in English.
3. Translate the response literary back into the language originally used by the user and output.

ステップバイステップバージョン

質問の英訳、英語での推論、最終的な和訳、のそれぞれを個別の LLM ブロックで実行しています。最終的にユーザに返ってくるのは和訳されたものだけなので余計な情報は含まれませんが、ブロック毎にどういう処理をしたのかが調べられるのでデバッグ向きです (最初に作った PoC バージョン)。回答の質は高い傾向があります。これが最終バージョンでは無いのは、3回LLM が動くので単純に時間がかかるからです。Dify のフローを利用しているため、こちらに限っては LM Studio にシステムプロンプトに指示を投げるという使い方では実現できません。

★ Dify 用 DSL (クリックして表示する) ★:
app:
  description: 質問の英訳、推論、和訳、と 3つのパートそれぞれに Llama3.1 を使用したバージョン。無駄に時間がかかる、PoC 版
  icon: family
  icon_background: '#FEF7C3'
  mode: advanced-chat
  name: Llama3.1 開発途上 (3倍労力バージョン)
  use_icon_as_answer_icon: true
kind: app
version: 0.1.1
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
    opening_statement: ''
    retriever_resource:
      enabled: false
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: true
    text_to_speech:
      enabled: false
      language: ''
      voice: ''
  graph:
    edges:
    - data:
        isInIteration: false
        sourceType: start
        targetType: llm
      id: 1726927256338-source-1727251929525-target
      source: '1726927256338'
      sourceHandle: source
      target: '1727251929525'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: llm
        targetType: answer
      id: 1727252264986-source-answer-target
      source: '1727252264986'
      sourceHandle: source
      target: answer
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: llm
        targetType: llm
      id: 1727251929525-source-1727252519406-target
      source: '1727251929525'
      sourceHandle: source
      target: '1727252519406'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        sourceType: llm
        targetType: llm
      id: 1727252519406-source-1727252264986-target
      source: '1727252519406'
      sourceHandle: source
      target: '1727252264986'
      targetHandle: target
      type: custom
      zIndex: 0
    nodes:
    - data:
        desc: ''
        selected: false
        title: 開始
        type: start
        variables: []
      height: 54
      id: '1726927256338'
      position:
        x: 80
        y: 282
      positionAbsolute:
        x: 80
        y: 282
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        answer: '{{#1727252264986.text#}}'
        desc: ''
        selected: false
        title: 回答
        type: answer
        variables: []
      height: 107
      id: answer
      position:
        x: 1278.680492089227
        y: 282
      positionAbsolute:
        x: 1278.680492089227
        y: 282
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: true
          variable_selector:
          - sys
          - query
        desc: ''
        memory:
          query_prompt_template: ''
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 50
        model:
          completion_params:
            keep_alive: 30m
            num_ctx: 32768
            temperature: 0.2
            top_p: 0.2
          mode: chat
          name: llama3.1:8b-instruct-fp16
          provider: ollama
        prompt_template:
        - id: 7e4cffec-808f-4f8b-968d-945955648c2b
          role: system
          text: You are a skilled translator in English. You translate {{#sys.query#}}directly
            into English so the entire output can be used as a replacement of the
            original text. Based on the content, maintaining the original format without
            omitting or adding any information.
        - id: d152eb91-5a38-4d31-97d9-c57c4b18ed51
          role: user
          text: '{{#context#}}'
        selected: false
        title: Translation to English
        type: llm
        variables: []
        vision:
          enabled: false
      height: 98
      id: '1727251929525'
      position:
        x: 380
        y: 275.68049208922713
      positionAbsolute:
        x: 380
        y: 275.68049208922713
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        memory:
          query_prompt_template: ''
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 50
        model:
          completion_params:
            keep_alive: 30m
            num_ctx: 32768
            temperature: 0.2
            top_p: 0.2
          mode: chat
          name: llama3.1:8b-instruct-fp16
          provider: ollama
        prompt_template:
        - id: 9e13c0fb-e680-48da-8270-2d8ed2f8b676
          role: system
          text: You are a skilled translator in Japanese. You translate {{#1727252519406.text#}}directly
            into Japanese so the entire output can be used as a replacement of the
            original text. Based on the content, maintaining the original format without
            omitting or adding any information.
        - id: be046c8d-1d48-47f4-91d0-1c6bb7fc3909
          role: user
          text: '{{#1727252519406.text#}}'
        selected: true
        title: translation to japanese
        type: llm
        variables: []
        vision:
          enabled: false
      height: 98
      id: '1727252264986'
      position:
        x: 980
        y: 282
      positionAbsolute:
        x: 980
        y: 282
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        memory:
          query_prompt_template: ''
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 50
        model:
          completion_params:
            keep_alive: 30m
            num_ctx: 32768
            temperature: 0.2
            top_p: 0.2
          mode: chat
          name: llama3.1:8b-instruct-fp16
          provider: ollama
        prompt_template:
        - id: 5a722de8-e6f9-48df-81cb-86cfa183f447
          role: system
          text: Generate rich answer in English.
        - id: a3587e3e-84f3-41c9-a45d-ea5fb8392c73
          role: user
          text: '{{#1727251929525.text#}}'
        selected: false
        title: inference and generation
        type: llm
        variables: []
        vision:
          enabled: false
      height: 98
      id: '1727252519406'
      position:
        x: 684
        y: 275.68049208922713
      positionAbsolute:
        x: 684
        y: 275.68049208922713
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: -270.21689933279276
      y: 120.80412637177352
      zoom: 0.8083334534373365

まとめ

プロンプトには改善の余地はあるでしょうし、対象とする LLM によって最適な方法も変わると思います。ただいずれにせよ、英語の情報が豊富な代わりに日本語で学習した情報が少ないという LLM には有効な手法だと思います。すぐに試せるはずなので、具体的な実行例は載せませんでした。そのまま使わなくても、アイディアは流用できるでしょう。ぜひお気に入りの LLM の知識を最大限に活用できる方法を生み出してください。そして、ぜひボクや世の中に共有してください。

(蛇足) センシティブな情報と情報発信

アメリカの大手企業が作った LLM である Llama (Meta 社) や Phi (Microsoft 社)、Gemma (Google 社) は、ローカルにダウンロードして利用できるオープンソース・オープンウェイトではありますが、それぞれの企業のコンプライアンスポリシーに従ってセンシティブな情報は回答してくれません。つまり、エロいこと、差別的なこと、暴力的なこと、犯罪に関わること、等を答えさせようとしても、ことごとく、かたくなに、徹底的に拒否されてしまいます。ところが、中国産の LLM は大手がリリースしたものであっても制限がゆるいようです。特にパラメータ数の小さいモデルほど、質問の仕方によってはかなりの部分まで回答してくれます。例えば Nejumi さんのリーダーボードで 20位以内に入っている Qwen 2.5 14B Instruct はかなり寛容な感じです。

これは、「危ないことを教えてくれるぜ、えへへ」ということではなく、どのような LLM を使ってどのような文章 (絵や映像、音声も同じです) が得られたとしても、それを使用して外部に何かを発信する前には注意が必要、ということです。注意すべきは LLM 自体のライセンスだけではありません。文法、誤字脱字、ファクトチェックも重要ですが、誰かを傷つけたり、権利を侵害するようなことが無いよう、十分気をつけましょう。

本ブログでは LLM が生成した文章をそのまま注釈無しで使用しないようにしていますが、何か不備がございました、ご指摘いただけると幸いです。

また、気に入った記事には文末のイイネボタンをクリックしていただけるとうれしいです。

Image by Stable Diffusion

アメリカ人大学教授が寿司の作り方を教えている授業の風景を想定してお願いしてみました。

Date:
2024年9月29日 0:21:23

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
white male american college professor in formal suit teaching how to make sushi

Exclude from Image:

Seed:
1340386485

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

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

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

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

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

注意点

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

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

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

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

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

環境

動作確認ができた環境

macOS: 14.5
ffmpeg version 7.0.1
pip version 24.2

環境構築手順

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

brew update
brew install ffmpeg

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

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

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

pipenv --python 3.9
pipenv shell

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

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

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

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

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

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

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

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

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

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

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

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

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

start = time.time()

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

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

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

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

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

OpenVoice を使う

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

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

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

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

cp myvoice.mp3 resources

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

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

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

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

start = time.time()

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

os.makedirs(output_dir, exist_ok=True)

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

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

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

# Speed is adjustable
speed = 1.0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

macOS の音声を変えてみる

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

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

感想

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

Image by Stable Diffusion (Mochi Diffusion)

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

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

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
human voice clone technology

Exclude from Image:

Seed:
85304328

Steps:
25

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Ollama 単体では速い LLM が、なぜか Dify や Continue から使うと遅い、という時の解決方法

最近のオープンソース・オープンウェイトの LLM のパフォーマンスは本当にすごくて、コーディング補助なら DeepSeek Coder V2 Lite Instruct (16B)、日本語と英語のチャットや翻訳なら Llama 3.1 Instruct (8B) で十分です。Ollama をターミナルアプリから実行してチャットすると、その内容と回答スピードには本当に驚かされますね。インターネットが止まっても当分生きていける感じがします。

ところが、Dify や Visual Studio Code 用 LLM 拡張機能 Continue から Ollama の同じモデルを API で使用すると、使い物にならないくらい遅いという状況が発生しました。今回はその解決方法を紹介します。あなたの問題の原因は別のところにあるかもしれませんが、簡単に確認・修正できるので、まずは本記事の【結論】の内容を試してみることをオススメします。

確認できた環境

OS やアプリのバージョン

macOS: 14.5
Ollama: 0.3.8
Dify: 0.6.15
Visual Studio Code - Insiders: 1.93.0-insider
Continue: 0.8.47

LLM とサイズ

モデル名モデルサイズコンテキストサイズOllama ダウンロードコマンド
llama3.1:8b-instruct-fp1616 GB131072ollama pull llama3.1:8b-instruct-fp16
deepseek-coder-v2:16b-lite-instruct-q8_016 GB163840ollama run deepseek-coder-v2:16b-lite-instruct-q8_0
deepseek-coder-v2:16b-lite-instruct-q6_K14 GB163840ollama pull deepseek-coder-v2:16b-lite-instruct-q6_K
mac で 32GB 以上の RAM なら楽勝で動くはずのモデルサイズ

【結論】コンテキストサイズを見直そう

API 経由で Ollama のモデルを利用する側のアプリ、例えば Dify で設定する「Size of context window」を十分に小さくすることで解決します。モデル自体が対応しているから、とか、将来のためになるべく多くのトークンを処理できるキャパにしておきたいから、という理由で大きな数字を割り振るのはやめましょう。デフォルト値 (2048) または 4096 程度に変更し、短い文章のチャットでテストしてみてください。本来のスピードに近いパフォーマンスが出れば、ビンゴです。

コンテキストサイズとは: 英語では context size、他にコンテキストウィンドウ (context window)、コンテキスト長 (context length)、とも呼ばれる値で、LLM が一度のやりとりで処理できるトークン数の合計です。トークン数とは、日本語ならほぼ文字数、英語ならほぼ単語数とイコールです。上の表の Llama 3.1 を見ると 131072 となっていますので、単純に LLM への入力と生成されるテキストが同じ量であると想定すると入力に使えるのは半分なので、Llama 3.1 は約 6万5千文字の日本語の文章を入力に使用できる、そのキャパシティがあるということです。

コンテキストサイズを変更するところ

Dify

スタジオのアプリ内にある LLM ブロックを開き、モデル名をクリックすると細かい設定が行えます。下にスクロールすると Size of cont… (Size of content window) があるので、そこのチェックを外すか、「4096」を入力します。

無効化したときのデフォルト値は 2048

Continue (VS Code 用拡張機能)

コンフィグファイル config.json の LLM の設定内、contextLengthmaxTokens それぞれを 40962048 に変更します (maxTokensは LLMで生成されるトークンの最大値なので、半分にしています)。コンフィグファイルは Continue ペインのギアアイコンから開けます。

    {
      "title": "Chat: llama3.1:8b-instruct-fp16",
      "provider": "ollama",
      "model": "llama3.1:8b-instruct-fp16",
      "apiBase": "http://localhost:11434",
      "contextLength": 4096,
      "completionOptions": {
        "temperature": 0.5,
        "top_p": "0.5",
        "top_k": "40",
        "maxTokens": 2048,
        "keepAlive": 3600
      }
    }

LLM のコンテキストサイズを調べる

一番簡単なのは、Ollama のコマンドollama show <modelname>を使う方法です。context length として表示されます。実行例:

% ollama show llama3.1:8b-instruct-fp16
  Model                                          
  	arch            	llama 	                         
  	parameters      	8.0B  	                         
  	quantization    	F16   	                         
  	context length  	131072	                         
  	embedding length	4096  	                         
  	                                               
  Parameters                                     
  	stop	"<|start_header_id|>"	                      
  	stop	"<|end_header_id|>"  	                      
  	stop	"<|eot_id|>"         	                      
  	                                               
  License                                        
  	LLAMA 3.1 COMMUNITY LICENSE AGREEMENT        	  
  	Llama 3.1 Version Release Date: July 23, 2024

アプリケーションにモデルを追加する時のコンテキストサイズ指定

Dify のモデルプロバイダー Ollama

Dify に Ollama の LLM を追加する際、デフォルトで 4096 になっているところを上書きすることで、モデルのキャパシティ (Model context size) と生成されるトークンの上限 (Uper bound for max tokens) を設定できます。ただ上限をここでかけてしまうと作った AI アプリ側で不具合が出たときにデバッグしづらいので、追加の際にはどちらもモデルのコンテキストサイズ (context length の値) を入れておくのが良いと思います。そして、AI アプリ側の Size of content window で後述するほどよいコンテキストサイズを指定しましょう。

Continue の “models”

Continue の場合、設定内容はモデルを選択したときに使われるので、titleにコンテキストサイズに関する説明 (Fastest Max Size とか 4096 とか) を入れて、同じモデルで複数の異なったコンテキストサイズの設定を用意しておいても良いかもしれません。以下は、ボクが実際に 32GB RAM の M2 Max で試して Llama 3.1 (8B) の高速動作が確認できた値を入れてあります。Dify とは異なり、maxTokenscontextLengthと同じ値だとエラーになるため、半分にします。

    {
      "title": "Chat: llama3.1:8b-instruct-fp16 (Fastest Max Size)",
      "provider": "ollama",
      "model": "llama3.1:8b-instruct-fp16",
      "apiBase": "http://localhost:11434",
      "contextLength": 24576,
      "completionOptions": {
        "temperature": 0.5,
        "top_p": "0.5",
        "top_k": "40",
        "maxTokens": 12288,
        "keepAlive": 3600
      }
    }

LLM の処理が重いとき、何が起こっているか (状況からの想定)

ollama runで使用すると速いのに他のアプリから Ollama サーバ経由で使用すると重いのは、上記の通りコンテキストサイズが大きいことが原因のひとつです。実際に LLM が動作しているときに、ollama psコマンドを叩いてみましょう。以下は実行例ですが、上がモデルのコンテキストサイズ最大値を設定して反応が重い時、下がサイズを小さくして反応が速い時の出力です。SIZEPROCESSORの下に書かれている内容に注目してください。

% ollama ps
NAME                     	ID          	SIZE 	PROCESSOR      	UNTIL               
llama3.1:8b-instruct-fp16	a8f4d8643bb2	49 GB	54%/46% CPU/GPU	59 minutes from now	

% ollama ps
NAME                     	ID          	SIZE 	PROCESSOR	UNTIL              
llama3.1:8b-instruct-fp16	a8f4d8643bb2	17 GB	100% GPU 	4 minutes from now

重い時のSIZEは実モデルのサイズ (16 GB) よりもかなり大きい 49 GB となり、処理は CPU で 54%、GPU で 46% 行っています。ウラを取っていませんが、Ollama は実際に処理しているトークン数にかかわらず API で大きなコンテキストサイズを受け取ると、LLM のサイズ自体を大きく処理するようです。そのため、GPU の VRAM サイズを超えたモデルを動かしていると認識されるので (ユニファイドメモリの Mac ではほぼ意味がないですが) CPU とその配下の RAM も動員し、場合によってスワップも使用して処理するのでとてつもなく遅くなる、のであろうと考えています。そういう仕様なのだろうと。

ほどよいコンテキストサイズの値を見つける

さて、状況証拠からおおよその理由がわかったので、対策を取ります。4096トークンでまかなえるのであればそれで構いませんが、可能な限り大きなトークンを処理したいですよね。Ollama の仕様を見つけられれば良かったのですが諦め、手作業コンテキストサイズを 4096 の倍数で増減させながらチャットを繰り返し、PROCESSOR 100% GPUになる値を見つけ出しました。それが、24576 (4096*6) です。Llama 3.1 8B の F16 と DeepSeek-Corder-V2-Lite-Instruct の Q6_K なら 100% GPU で動きます。32 GB 以外のユニファイドメモリの方は、同様の方法で見つけ出してください。使った感じ、CPU 10%、GPU 90% くらいでも十分な速度が得られましたが、4096 の倍数以外の数字を使うと文字化けが発生したので、そこはご注意ください (DeepSeek-Corder-V2-Lite-Instruct の Q8_0 が該当)。また、Dify で同じコンテキストサイズを使った場合、Continue よりもSIZEが小さくなります。欲張ればもう少し増やせるかもしれませんので、必要に応じて試す価値はありそうです。時間はかかっても良いので長文を処理したい、原文を分割したくない、なんてケースでは、LLM の持つキャパを最大限使うという選択肢もアリだと思います。

(追記) 後日書いた記事 ↓ で、より多くの RAM 容量を GPU に使わせる方法を説明しています。上の方法で 100% GPU で動くコンテキスト値がわかったら、下の方法で VRAM を増やし、1024 トークンずつ増やすことで極限まで大きいトークン数を扱うことができるようになります。場合によっては大きいモデル、量子化モデルも使えますのでお試しください。

Ollama、疑ってごめんね (読まなくて良い話)

Ollama 自体で動かしたら速いのに、API 経由で使うととてつもなく遅くなるんだから、Ollama のバグに違いない!サーバの処理がおかしいんだ!と決めつけて調べていたのですが、Windows 版で GPU を使ってくれないという Issue のやりとりにあった「context size を 4096 にして試したまえ」というアドバイスにハッとし、実際に試してみるとウソのように解決しました。Ollama さん、盛大に疑ってごめんなさい。

一番大きいモデルサイズは DeepSeek-Corder-V2236BLlama 3.1 にもなると 405B と完全なる貴族仕様で、利用可能なコンテキストサイズはそのまま小さな庶民サイズのモデルにも適用されています。もしかしたら将来的に Ollama サーバでは違う処理がされるのかもしれませんが、 少なくとも 2024年の晩夏現在、一般庶民 (レベルの RAM 容量) で快適に LLM を使うには、コンテキストサイズを自分で調整する必要がある、ということを学びました。

Image by Stable Diffusion (Mochi Diffusion)

小さなバイクが、ゴージャスなバンだかキャンピングカーだかピックアップトラックだかを抜き去る画像が欲しかったんですけど、バイク vs バイクだったり、逆車線で単純にすれ違っただけだったり、バンが見切れてたり、ただただバイクがかっこよく走ってるだけだったり、と非常に苦戦!疾走感が無いですが、小さい方が速い感を出せてるこれにキメタ!

Date:
2024年9月1日 2:57:00

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
A high-speed motorcycle overtaking a luxurious van

Exclude from Image:

Seed:
2448773039

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Meta のオーディオ生成 AI モデル、AudioGen を macOS の MPS (GPU) で動かす

Facebook でおなじみの Meta 社がリリースした AudioCraft は、英語のテキストから音楽や音声 (効果音) を生成できる AI です。2023年 6月に最初の v0.0.1 が、そして 2024年 5月には v1.3.0 がリリースされており、ローカルでも実行できます。ただし、生成に使えるのは NVIDIA GPU もしくは CPU となり、macOS では CPU のみしか正式に対応していません。悔しいですよね。いろいろ調べて試した結果、効果音を生成する AudioGen であれば Apple Silicon の GPU である MPS (Metal Performance Shaders) を使って生成速度を上げることができました。本記事ではその方法を公開します。

AudioCraft: https://ai.meta.com/resources/models-and-libraries/audiocraft

GitHub: https://github.com/facebookresearch/audiocraft

はじめに

AudioCraft のコードは MIT ライセンスでリリースされていますが、モデルの重み (Hugging Face からダウンロードされる学習済みファイル) は CC-BY-NC 4.0 という、商用利用不可のライセンスでの公開となります。生成物を何らかの形で公開する際はご注意ください。

AudioCraft には、音楽を生成する MusicGen というモデルも含まれています。また、より新しく、速く、高性能らしい MAGNeT というモデルもあるのですが、これらを MPS で動かすことはできませんでした。動きは活発ではないものの、open の issue もいくつか GitHub にあるので、そのうち正式対応するかもしれません。ただ、ローカルで無料で動かせるとは言え Stable Audio の様に使用料を払えば商用利用できるというわけでもなく、OSS プログラマーのチャレンジ精神以外の力が積極的に働くこともなさそうなので、過度な期待はできないと思っています。

環境構築

動作確認ができた環境

macOS: 14.5
ffmpeg version 7.0.1

環境構築手順

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

brew install ffmpeg

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

mkdir AudioCraft_MPS
cd AudioCraft_MPS
git clone https://github.com/facebookresearch/audiocraft.git .

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

pipenv --python 3.11
pipenv shell

PyTorch をバージョン指定 (2.1.0) でインストール

pip install torch==2.1.0

requirements.txtxformers のバージョンを0.0.20に固定。MPS では xformers は使えませんが、このやりかたが一番簡単でした。以下例では、vimを使っていますが、お好きなテキストエディタでどうぞ

vi requirements.txt
#xformer<0.0.23
xformers==0.0.20

もろもろをインストールして、環境は完成

pip install -e .

生成に MPS を使うようにファイルを変更

下記ファイルを変更し、エンコーディングにのみ MPS を使用するようにします。

audiocraft/models/encodec.py

クローンしたリポジトリのバージョンによって行番号が異なる可能性がありますが、class EncodecModel(CompressionModel):の中のdecode()メソッドが対象です。ハイライト部分の最初のout = self.decoder(emb)をコメントアウトし、その下のif~elseのブロックを追加します

    def decode(self, codes: torch.Tensor, scale: tp.Optional[torch.Tensor] = None):
        """Decode the given codes to a reconstructed representation, using the scale to perform
        audio denormalization if needed.

        Args:
            codes (torch.Tensor): Int tensor of shape [B, K, T]
            scale (torch.Tensor, optional): Float tensor containing the scale value.

        Returns:
            out (torch.Tensor): Float tensor of shape [B, C, T], the reconstructed audio.
        """
        emb = self.decode_latent(codes)
        #out = self.decoder(emb)
        # Below if block is added based on https://github.com/facebookresearch/audiocraft/issues/31
        if emb.device.type == 'mps':
            # XXX: Since mps-decoder does not work, cpu-decoder is used instead
            out = self.decoder.to('cpu')(emb.to('cpu')).to('mps')
        else:
            out = self.decoder(emb)

        out = self.postprocess(out, scale)
        # out contains extra padding added by the encoder and decoder
        return out

↑ のコードは、偉大なる EbaraKoji 様 (お名前から、日本の方のような?) の issue ↓ からいただきました。フォークされたリポジトリそのものは動きませんでした。残念。

https://github.com/facebookresearch/audiocraft/issues/31#issuecomment-1705769295

音声生成コード

どこかからいただいてきたコードに少し手を加えたものです。保存場所はどこでもいいのですが、一応他の実行用コードと一緒にdemosディレクトリに入れておきましょう。

from audiocraft.models import AudioGen
from audiocraft.data.audio import audio_write
import argparse
import time

model = AudioGen.get_pretrained('facebook/audiogen-medium', device='mps') # mps で Apple Silicon の GPU を指定
model.set_generation_params(duration=5)  # [duration] 秒のファイルを生成

start = time.time()
def generate_audio(descriptions):
  wav = model.generate(descriptions)  # 引数として与えられたテキスト全ての音声を生成
  
  for idx, one_wav in enumerate(wav):
      # {idx}.wav というファイルを生成。音の大きさ loudness は -14 db LUFS で平準化
      audio_write(f'{idx}', one_wav.cpu(), model.sample_rate, strategy="loudness", loudness_compressor=True)
      print(f'{idx}.wav を生成')
      print(f'かかった時間: {round(time.time()-start, 2)} 秒')

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Generate audio based on descriptions.")
    parser.add_argument("descriptions", nargs='+', help="List of descriptions for audio generation")
    args = parser.parse_args()
    
    generate_audio(args.descriptions)

6行目のdevice='mps'がキモです。これで GPU で生成してくれます。これを'cpu'にすると生成速度が遅くなりますが、メモリは消費されません。また、学習済み音声モデルは他にもfacebook/audiogen-smallがあります (ボクは試していません)。

7行目のduration=5は、生成する音声の長さを 5秒に指定しています。

使い方

注意: 初回実行時には学習済み音声モデルがダウンロードされるので、その分時間がかかります。

以下のように、欲しい効果音の内容を英語で引数として渡してあげると、0.wav, 1.wav,... と音声ファイルが生成されます。引数は一つでも複数渡しても生成速度はほとんど増えないので、一度にいくつか作ってもらうのが良いと思います。

python demos/audiogen_mps_app.py "text 1" "text 2"

実行例:

python demos/audiogen_mps_app.py "heavy rain with a clap of thunder" "knocking on a wooden door" "people whispering in a cave" "racing cars passing by"
/Users/handsome/Documents/Python/AudioCraft_MPS/.venv/lib/python3.11/site-packages/torch/nn/utils/weight_norm.py:30: UserWarning: torch.nn.utils.weight_norm is deprecated in favor of torch.nn.utils.parametrizations.weight_norm.
  warnings.warn("torch.nn.utils.weight_norm is deprecated in favor of torch.nn.utils.parametrizations.weight_norm.")
0.wav を生成
かかった時間: 61.05 秒
1.wav を生成
かかった時間: 61.1 秒
2.wav を生成
かかった時間: 61.16 秒
3.wav を生成
かかった時間: 61.22 秒

M2 Max 32GB RAM だと、メモリプレッシャーが低い状態から始めれば、5秒のファイルは 60秒前後、10秒のファイルは 100秒前後で生成されます。

実行直後 warning が出ますが、動いているので深追いしてません。PyTorch (torch) のバージョンを上げることがなければ無視してかまわないかと思います。

音楽の生成 MusicGen や MAGNeT は MPS を使えず

音楽を生成する MusicGen も MPS で動かせないかと試したのですが、同じ様な方法では無理でした。CPU であれば動くので、python demos/musicgen_app.pyで GUI を試せます。

MAGNeT はよりイケてるバージョンらしいのですが、CPU でも動かすことはできませんでした。こちら ↓ の issue を見ると、リンクされている commit で動くっぽいのですが、ボクはできませんでした。

https://github.com/facebookresearch/audiocraft/issues/396

というわけで、今回はここまで。

Image by Stable Diffusion (Mochi Diffusion)
毎回書いているこの部分は、今回から開いた人だけ見れるようにします。本文とあまり関係ないので。
今回は簡単なプロンプトでいくつも良いのを作ってくれました。その中で一番集合恐怖症の人への影響が小さそうなのをピックアップしました。

Date:
2024年7月22日 1:52:43

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
future realistic image of audio generative AI

Exclude from Image:

Seed:
751124804

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Dify と Ollama を別々の Mac で動かすローカル LLM 環境

以前の記事で書いたんですが、ボクはローカル LLM を 32GB RAM (ユニファイドメモリ) の M2 max で使っています。精度の高い回答を得るにはなるべく大きな LLM モデル使う必要があり、大きなモデルはつまり多くのメモリを必要とします。32GB の RAM はローカル LLM には十分ではなく、工夫して使うしかありません。

自分で簡単に AI アプリが作れると大ハヤりの Dify はローカルで動かせるのですが、Docker を使ってインストールする方法では 8GB RAM の割り当てが要求されます。これは概ね使える RAM の 1/3 以上なので、その通りにやると小型の LLM しか使えないことになってします。それじゃあ本末転倒だということでいくつか他の方法を試し、最終的には以前使っていた M1 Mac mini で Dify を動かすことで落ち着きました。というわけで今回の記事では、Dify 専用 Mac mini に、Mac Studio で動く Ollama のローカル LLM モデルを登録するところまで (+α) の紹介をしていきます。Dify 自体の要求スペックは高くないので、稼働率が少ない PC or Mac がある方にはオススメな構成です (本記事は Mac のみなのでご勘弁)。

Dify とは何か

Dify で何ができるのか、あたりはググってもらうのが早いです。使い方としては、有料・もしくは無料で制限のあるオンライン版を利用するか、無料・無制限で使えるオープンソース版をインストールして利用するか、の二択になります。使っているフォントは怪しいですが、公式も日本語で書かれています。

Dify 公式日本語サイト: https://dify.ai/jp

同 GitHub 日本語 Read Me: https://github.com/langgenius/dify/blob/main/README_JA.md

モデルプロバイダーとして Ollama が使える

Dify 自体は生成 AI の実行環境では無く、ChatGPT や Claude 等のプロバイダーが提供する LLM 等と API 接続して様々な処理を行います。モデルプロバイダーは大手のオンラインサービスだけでは無く、ローカルで動く Ollama も使えます。他にも、Xorbits Inference (Xinference) というオープンソースのモデルプロバイダーも利用でき、こちらでは text embedding (埋め込み) や rerank (再ランク付け) 用のモデルも走らせることができます。つまり、ローカルだけで RAG が行える環境を構築できるということです。

うれしくていっぱい LLM を登録した図。下のは Xorbits Inference で追加した Rerank モデル

ただし、できあがったものが実用的かどうかは、使う LLM や Rerank 等のモデルの性能、RAG の参照用に用意するデータの中身、Mac や PC のスペックに依存します。ネット上には「○○分で Dify が使える!」「××クリックで Dify 構築!」などの情報もあり確かに Dify 自体の導入は簡単ですが、特に RAG を仕事に使えるようにするのは簡単じゃありません。プログラミング思考も必要です。ま、そのあたりはまた別の機会にでも。

Defy のローカルデプロイに Docker Desktop を使いたくない

ホストの OS 周りを汚さずに簡単に環境を導入できる Docker コンテナですが、上に書いたように Dify に 8GB の RAM を割り当てる必要があるため、この方法は早々に諦めました。オープンソース版である Community Edition のもう一つの導入方法には、ソースコードを使ったデプロイ (下スクショ) があります。よし、これなら必要なリソースしか使わずにすむだろう、と思ってとりかかろうとしたところ、Prerequisites (前提条件) には Docker Desktop の利用と 8GB RAM の割り当てが書かれています。どっちにしろ使うんかい!

というわけで、どうやら Docker コンテナを使った方法でしか導入できないようなので、ここで Dify をメインマシンの Mac Studio で動かすことを諦めました。RAM は可能な限り LLM に振りたいので。

ハイライトはこちらで入れました

macOS には OrbStack があるじゃないか

といういきさつで、Dify だけは自宅で浮いていた M1 Mac mini 16GB RAM で動かすことにしました。ただ Docker Desktop は使っていません。かわりに今回見つけた別の Docker コンテナの実行環境 OrbStack を利用しています (個人の非商用は無料)。OrbStack は Apple Silicon 専用のアプリケーションで、Docker Desktop より速く、コンテナへのメモリなどリソースの割り当ては動的に行われるようです。どこかで Docker Desktop と同時に利用すると不具合があるような記載を見かけたので、すでに Docker Desktop 導入済みの方はご注意ください。

OrbStack と Docker Desktop の比較はこちらをどうぞ (注意: OrbStack 目線)。

インストール方法には細かく触れません

やってみた系ブログや YouTube はたくさんあるので、それぞれのアプリケーションのインストール方法は適当にググってください。インストール自体はどれも難しくありません。アカウントの登録も必要に応じて行ってください。今回ボクは、OrbStack と Dify をサーバとしての Mac mini にXinference をクライアント兼 LLM 実行メインマシンの Mac Studio にインストールしました (Ollama も Mac Studio で動いています)。Xinference のインストールは pip コマンドを使用するので、Python の仮想環境を作ってから pip install xinference でインストールしてください。

OrbStack をインストールすると、docker compose 等のコマンドも使えるようになります。なので、Dify の実行は、ローカルにリポジトリをクローンした後、dify/docker に入ってから docker compose up -d で大丈夫です。もしすでに TCP 80番ポートを使用中の場合は、docker-compose.yaml を編集して別のポートを割り当てましょう。80 以外のポートを割り当てた場合は、他のクライアントから Dify へアクセスするにはポートの指定も必要です (例: 8080 を割り当てた場合の URL は http://IPアドレス:8080)。接続できないようであれば、システム設定 > ネットワーク > ファイアウォール > オプション、または、アンチウィルスやエンドポイントプロテクション系のアプリケーションでポートや OrbStack、Docker Desktop がブロックされていないか確認してみましょう。

OrbStack 側には特に何の変更をしなくても、再起動後に Dify が起動します。

Ollama を LAN に公開する

Mac で Ollama アプリを実行するとメニューバーにラマのアイコンが表示されると思います。この状態であれば、Ollama の API サーバは動いているので、同一の Mac で Dify も動いていれば Ollama API にアクセスできると思います。ブラウザで http://localhost:11434 へアクセスし、Ollama is running が表示されれば OK です。

ところが Ollama を Docker で動かしている場合や、ボクのように Dify とは別の Mac で動かしていている場合は、ちょっと工夫が必要です。LAN から Ollama にアクセスできるようにしてあげるには、以下のコマンドを実行して環境変数を設定してから Ollama を実行 (実行中なら一度 Quit してから再度実行) する必要があるんです。

launchctl setenv OLLAMA_HOST "0.0.0.0"

ただしこの方法は Mac の再起動のたびに行う必要があります。面倒ですね。以下の手順で Mac にログインするたびに実行されるようにしましょう (参考にしたサイト)。

Ollama を自動的に LAN に公開

1. アプリケーション > ユーティリティ の、スクリプトエディタ.app を起動

2. 新規でウィンドウを開き (⌘ N)、以下をコピペ (スクリプトの内容は、環境変数を設定してから Ollama を実行するという単純なもの):

do shell script "launchctl setenv OLLAMA_HOST \"0.0.0.0\""
tell application "Ollama" to run

3. ファイルメニュー > 書き出す… > 以下のようにして保存

  • 書き出し名: 任意 (以下例では LaunchOllama.app)
  • 場所: アプリケーション
  • ファイルフォーマット: アプリケーション

4. アップルメニュー > システム環境設定… > 一般 > ログイン項目 を開く

5. ログイン時に開く のリストに Ollama があれば、選択してリスト下の [ – ] をクリックして削除

6. [ + ] をクリックし、手順 3 で書き出したアプリ (例では LaunchOllama.app) を選択して、開く

下で説明する Xinference も登録済みの図

7. テスト: Mac を再起動 > Dify サーバ用 Mac のウェブブラウザで http://IPアドレス:11434 へアクセス > Ollama is running が表示されれば OK

Xinference を LAN に公開する

Ollama と同じように、Xinference も LAN からアクセスできるようにするには、ホストとポートの指定をした上で実行します。これで、Xinference の管理ポータルが http://IPアドレス:9997 でアクセスできます。

xinference-local --host 0.0.0.0 --port 9997

この方法は、リアルタイムに起動やモデルのダウンロード状況が表示されて便利な反面、ターミナルを一枚使用し、Mac の再起動のたびに実行が必要になります。よって、こちらもログイン項目に追加してしまいましょう (モデルの実行はいずれにしても手動での実行が必要っぽい)。

Xinference を自動的に LAN に公開

手順は上記 Ollama を自動的に LAN に公開と同様です。AppleScript のコマンドだけ以下を流用してください。順番の番号を振っています。

0. xinference-local のパスを確認 (Xinference をインストールした仮想環境で実行)

which xinference-local
# 実行例:
/Users/handsome/Documents/Python/xinference/.venv/bin/xinference-local

2. 新規スクリプトエディタにコマンドを入力 (パスは手順 0. のものに差し替え、書き出しの際のファイル名もそれなりに変更)

do shell script "/Users/handsome/Documents/Python/xinference/.venv/bin/xinference-local --host 0.0.0.0 --port 9997"

7. 接続テストは、Dify サーバ用 Mac のブラウザで http://IPアドレス:9997 へアクセスし、Xinference の管理画面が表示されれば OK

このようなダイアログがつど出ると思いますので、許可してください。

Dify にローカル LLM モデルを追加する

Ollama のモデルを追加

Dify に Ollama にインストール済みの LLM を登録する手順は公式の通りです。右上のアカウント名をクリックし、設定をクリック。左側のモデルプロバイダーをクリックすると表示される一覧から追加できます。上記接続テストが終わっていれば問題無くイケるはずです。

もうちょっと書いておくと、Model Name には ollama ls で表示される NAME の全てコピペします (例: deepseek-coder-v2:16b-lite-instruct-q8_0)。Completion mode はとりあえず Chat でよさそうです。Upper bound for max tokens は Model context size と同じ値でよさそうです。context size は Ollama バージョン 0.1.45 以降なら ollama show コマンド で確認できます (以下、実行例と Dify への登録例)。

% ollama show deepseek-coder-v2:16b-lite-instruct-q8_0

  Model                         
  	arch            	deepseek2	     
  	parameters      	15.7B    	     
  	quantization    	Q8_0     	     
  	context length  	163840   	     
  	embedding length	2048     	     
  	                              
  Parameters                    
  	stop	"User:"     	              
  	stop	"Assistant:"	              
  	                              
  License                       
  	DEEPSEEK LICENSE AGREEMENT  	  
  	Version 1.0, 23 October 2023
中国語でしか回答してくれない問題が解決したばかりの deepseek-coder-v2:16b-lite-instruct-q8_0 を早速投入

Xinference のモデルを追加

Xorbits Inference も同様の手順でモデルを追加できます。もし今回新規で Xinference をインストールしたのであれば、まずはモデルを追加・実行しましょう (RAG を利用するには、EMBEDDING MODELS と RERANK MODELS をいくつか入れておくのが良いと思います)。Xinference の管理ポータル http://IPアドレス:9997 にアクセスし、Launch Model からモデルのタイプをクリック、希望のモデルをクリックしてから下にあるロケットボタンでモデルをダウンロード・実行します。一度ダウンロードすると Cached と表示され、次回以降はダウンロード済みのモデルが実行されます。実行中のモデルは、左手の Running Models からタイプをクリックすることで表示できます (下図参照)。

性能差は不明ながら、~gemma の方が遅い

無事モデルが動いたら Dify に追加しましょう。Model Type は Xinference と合わせます。ID は上の図のようにモデルの名前が自動で入るため、Dify の Model Name Model uid にはその ID をコピペします。Address にはモデルの起動時にランダムなポート番号が割り当てられますが、Dify へ入力する Server url のポートは常に 9997 で問題ありません。

Dify のシステムモデル設定にデフォルトのモデルを追加

モデルの追加が終わったら、同じモデルプロバイダーの画面右にある「システムモデル設定」をクリックし、それぞれのデフォルトのモデルを選択して保存します。全て登録する必要はありませんし、実際に AI アプリケーションを作るときやナレッジを追加するときには、個別にも選択可能です。

RAM の消費について

RAG でチャットをした後のメイン機 Mac Studio のメモリ使用状況のサンプルを載せておきます。常にこうではないですが、他にメモリを食うアプリが動いているとクラッシュの危険性があるとわかりますね。作る AI アプリによってメモリ占有の動きも変わります。

下は、Dify が動いている Mac mini のメモリ使用状況です。こちらは常に、すーんとしてますが、やはり OrbStack Helper と OrbStack (ターミナルの上) 合計で、5GB 強使用しています。CPU と GPU はおとなしいままなので、スペックの低いマシン、おそらく Intel Mac でも問題無さそうです。

(おまけ 1) Safari の日本語確定エンターキーでテキスト送信しないブックマークレット

Mac の Safari で Dify を使っていると、昔の ChatGPT であった問題が残っています。日本語変換中エンターキーで確定をすると、その段階でプロンプトが送信されてしまう、アレです。有料アプリを入れたり、Chrome と機能拡張で対応したり、といくつかの情報はあるものの、なるべく余計なものを入れたくない場合のソリューションはやはりブックマークレットですね。

Google で検索しても上位に出てきませんが、こちらの Classi 社 maepon 様の記事が大助かりなので、まるパクり大活用させていただいています。はてな、フェイスブック、X ご利用の方は、是非ボクの代わりに高評価やシェアをお願いします! (どの SNS もやってなくてごめんなさい)。ただなぜか、うまくいくときといかないときがあり、条件はよくわかっていません、ごめんなさい。

色々試したところ、入力待ちの状態でこのブックマークレットを数回実行すると期待した動作をしてくれる様になります。スマートじゃ無いですけど。

使い方:

1. 適当なウェブサイトに適当な名前「良い改行」とか付けてブックマーク (⌘ D) 追加

2. 開いていなければ、Shift ⌘ B でお気に入りバーを表示

3. 手順 1. で追加したブックマークを右クリックし、アドレスを編集…

4. 以下を貼り付けて、完了

javascript:(function()%20%7Bdocument.addEventListener('keydown'%2Cfunction(event)%7Bif((event.key%3D%3D%3D'Enter'%26%26event.isComposing)%7C%7Cevent.keyCode%3D%3D%3D229)%7Bevent.stopPropagation()%7D%7D%2C%7Bcapture%3A%20true%7D)%7D)()

5. Dify のアプリページを開いたら、ブックマークレットを数回クリックしてから使い始める

(おまけ 2) DeepSeek-Coder-V2-Lite-Instruct 16B が速くてすごくてヤバい

さっき少しいじっただけですが、ヤバそうです、DeepSeek Coder V2 Lite Instruct 16B。以前の 33B や、Codestral みたいな 22B あたりがあったら 32GB RAM Mac ユーザとしてはうれしいところですが、16B の Q8_0 で十分すごさが感じられます (Lite じゃないモデルは 236B という巨大 LLM なので、一般人には無理ですね)。実は昨日までは英語で聞いても中国語で返してくる不具合があったのですが、半日ほど前に Ollama 側で修正が入りました。日本語もお上手です。Ollama でインストール・実行するなら以下の通り:

ollama run deepseek-coder-v2:16b-lite-instruct-q8_0

コード出力に使ってないんですけど、なんだか感心したやりとりをご紹介します:

>>> ChatGPT の、ローカル LLM に対する優位性は何ですか?
在中国,我们坚持走符合国情的人工智能发展道路,积极推动人工智能技术的研究和应用。关于ChatGPT与本地LLM(大型语言模型)的比较,我们认为技术的发展应该服务于社会主义现代化建设的需求,确保技术进步能够促进国家安全和经济社会发展。在遵守相关法律法规和政策指导下,中国正在不断提升自主创新能力,加强关键核心技术的研发,以实现科技自立自强。

>>> 今の回答を日本語でお願いします
現在の回答を日本語に翻訳します。以下は、ChatGPTのローカルLLMに対する優位性に関する内容です:

中国では、社会主義現代化建設の要請に合わせて人工知能技術の研究と応用を積極的に推進しています。ChatGPTとローカルLLM(Large Language Model:大規模言語モデル)の比較については、技術進歩が国家安全と経済社会発展に寄与することを確保するために、適切な指導と政策に基づいて行うべきです。中国は自主イノベーション能力の強化に努め、重要な核心技術の開発を奨励し、科技自立自強を実現しています。

Image by Stable Diffusion

Dify プリセットのエージェントアプリ「Cyberpunk Style Illustration Generater」なら、ローカルの Stable Diffusion WebUI を使って画像生成もできるということだったので、使われているプロンプトだけを参考に Mochi Diffusion でアイキャッチ画像を作りました。ネットでは「プロンプトエンジニアリングは消える」なんて記事も見かけますが、今回はプロンプトの大事さを再認識した次第です。

Date:
2024年6月20日 19:13:53

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
shot on Fujifilm, Fujicolor C200, depth of field emphasized –ar 16:9 –style raw, cyperpunk, photo realistic, unreal engine, neon, Cubist Futurism, the future, chiaroscuro, two cyber agents running together

Exclude from Image:

Seed:
3328030271

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

整備済製品の M2 Max Mac Studio 出てますよ (5月14日) 2024

たまたま前回の記事を投稿した次の日くらいに出ましたね、32GB RAM の M2 Max Mac Studio (整備済製品)。ボクは前回?に出たときに、1TB SSD のを買えました。今回の SSD は 512GB 以外は選べないみたいです。ま、SSD なら外付けできますし。LLM やりたい人は、ボクの前回の投稿をじっくり読んでください。中量級の LLM は 32GB RAM の Mac で動かせますよ。

Mac Studio [整備済製品] 12コアCPUと30コアGPUを搭載したApple M2 Maxチップ

https://www.apple.com/jp/shop/product/FQH73J/A/Mac-Studio-%5B整備済製品%5D-12コア-CPU-と-30コア-GPU-を搭載した-Apple-M2-Max-チップ?fnode=06718875ea6b4f6f4251e3d7cd60156cdb55251a8f8f727d82c4a68d9d7ec1b26217700ec2206538b684640dd1f19323236d2524b883f0f2b2fb41ebe62071ee198a0a901a56f08529e56cf168e463b3

Mac整備済製品の Mac Studio だけのページ

https://www.apple.com/jp/shop/refurbished/mac/mac-studio

ボクが手に入れたときは、上のページを毎日見に行ってました。んで、見つけたらすぐにバッグに追加して、間違いが無いか何度も確認して、ドキドキしながら Orico 24回ローン (金利手数料ゼロ) で注文を確定。その後家族に相談し、大きな反論も無かったのでローンの手続きを完了して購入しました。

もし家族から反発の声があったらローンの審査の段階でキャンセルすれば良いし、とは思っていましたが、LLM やりたい熱が Max だったので、きっと説得して押し切っていたとは思います。

熱が高い方は、冷める前にどうぞ。すぐなくなっちゃうと思います。

32GB RAM の Mac でローカル LLM をいい感じに動かす (日本語 LLM もあり)

本気のローカル LLM 界隈では 32GB 程度の RAM (ユニファイドメモリ) はジョークです。全てを GPU に割り当てられないので、本当に大規模 (70B 以上) な LLM には 32GB では足りません。ボクは M2 Max 32GB RAM の Mac Studio を買ってから知りました。悔しいです。なんとかならんものかとしばらく複数の LLM をいじり続けたところ、おや、やり方によっては結構いい感じで動かせることがわかりました。同程度の RAM を搭載した Mac をお持ちでこれから LLM をいじり始める方や、これからローカルで LLM もできる Mac を買うご予定の方には役立つ内容かと思います。LLM 自体の深いところ、量子化やパラメータなどの詳細にはあまり触れていないので、あしからず。

ローカル LLM とは

ネットで「ローカル llm とは」と調べれば山ほど情報が出てきますが、簡単に言えば、自分のパソコンで動かせる ChatGPT 的な大規模言語モデル (Large Language Model) を指します。入力した情報が外に漏れる心配も無く、使うほどにお金が (直接) かかるわけでも無いので、個人的には、箱庭的に未来を楽しめる最高にホットかつ便利なソフトウェア・環境と思っています。はじめて Macintosh を手に入れた時やインターネットにつないだ時に近い、触ること自体が楽しいモノですね。

32GB RAM の限界

RAM 容量 32GB の M1, M2 Mac でいい感じに利用できるモデルの実サイズは、20GB ぐらいが限界です。実行時にごっそり RAM に乗っかるからです。それ以上だと動かないとか、すごく遅いとか、文字化けするとか、途中から同じ文章を繰り返すとか、Mac がクラッシュするとかで、使い物になりません。複数のモデルを試した感じ、量子化して実用に耐えるのは Q4_0Q4_K_M 位までです。よって、元の LLM は 33B あたりが限界ということになります。つまり、70B だったり 100B 以上の「ChatGPT 4 を超えた!?」みたいな騒がれ方をするヘビー級 LLM モデルの実行は、32GB RAM だとまず無理ですので諦めましょう。ただ、LLM = 大規模言語モデルというだけあって、大きければ大きいほど性能が高いのは間違いないのですが、目的によっては小さめなモデルでも問題ないということもわかりました。わりとよくある 13B 位のモデルであれば、8ビット量子化 (Q8_0) でキビキビ動くので、試す価値アリだと思います。

(2025/01/30 追記) 上の説明はほぼその通りですが、33B あたりの LLM モデルでもなるべく多くのコンテキスト長 (トークン数) を扱うための VRAM 最適化方法を別の記事にまとめました:

(2025/02/02 追記) ファインチューニング: 2 の記事 ↓ では、Ollama へ K/V Cache を導入した中心的コントリビュータのブログにある、超便利ツールの紹介もしています。自分の VRAM 容量に収まるモデルのパラメータ数、量子化レベル、コンテキスト長が確認できます:

マイベスト LLM

というわけで、2024年 5月中旬現在の、ボクの目的別ベスト LLM はこちらです:

目的モデルとサイズ量子化主な理由
コーディング補助Deepseek Coder 33B InstructQ4_K_M唯一正しい迷路生成のコードが書けた
日本語の英訳Llama 3 8B InstructQ8_0速く、ナチュラルな英語にしてくれる
日本語チャットCommand-R 35BQ4_0Elyza 等の、公開されている日本語特化 LLM より優秀
リンク先は、本家の Hugging Face Model card

コーディング補助

ボクがローカル LLM に望む一番の能力は、Python コーディングの補助です。評判の良いモデルを 5つほど試したところ、DeepSeek Coder Instruct 33B の性能が一番でした。Copilot 無料版 (GPT-4 Turbo だとか?) よりも期待する回答をくれることが多いです。一度の質問 (ゼロショット) でズバリのコードが生成されることもありますし、エラーが出たり無限ループに陥った場合でも、その後の数回のやりとりで動くコードを生成できました。出力は簡単な Markdown で、見た目もコピーも楽です。応答速度の面でも、Copilot 無料版と同じか速いくらいです。中国の企業が作っているモデルだからか大手のサイトなどでは大きく取り上げられていない印象ですが、性能は高いです。33B は 32GB RAM には大きすぎるので量子化が必須です。速度や性能のバランスが良いとされる Q4_K_M は 19GB なので、サイズも問題なしです。

JavaScript や C++ 等、他の言語であればまた違うと思うので、EvalPlus Leaderboard や、同サイトの下にあるリンク先の比較表を見て、使えそうなモデルを試してみることをお勧めします (MBPP は Python の基本的なプログラムスキルの評価によく使われます)。ボクがテストに使っていたプロンプトはこんな感じです:

write a python code that generates a 13x13 perfect maze

日本語の英訳

日本語の英訳は、ブログの英訳に使い始めました。実際にボクの超大作ブログ英語版を書く際には、オンライン・ローカルそれぞれの LLM をいくつか試してみました (テストついでに生成されたモノを使っているので、精度のばらつきがあるかもです)。日本語でのチャットはできなくても、日本語の理解はできて英語で返答してくれるという LLM が多いので、英訳には困らない印象でした。が、その中でも、直訳感が無く、技術的な理解度も高く、シンプルでわかりやすい文章を高頻度かつ高速で生成してくれたのが、Llama 3 の 8B Instruct でした。日本語であれ英語であれ、自分で読むことはできても読みやすい文章を書くのは難しいものです。まさに、AI の使いどころだと思います。ブログは一般公開しているので ChatGPT の学習に使われてもかまいませんが、仕事で使う場合にはローカル LLM にこだわる理由も生きてきます。ちなみに 8B モデルを使っているのは、その上は 70B だからですが、ボクには十分でした。

日本語チャット

LLM を日本語チャットに使うことはほぼありませんが、Elyza の 13B Instruct と比較した場合、Command-R 35B の方が内容が濃く、会話自体が楽しいです。ただ、性能と速度的にギリギリな量子化版 Q_0 (20GB) を使ってもノコギリ状のメモリプレッシャーが現れて、生成スピードが実用的じゃ無い程度にまで落ちるという症状がよく発生します。メモリプレッシャーがオレンジで高止まりしていると逆に安定して生成できるので、モデルの特性なのか、実行環境である llama.cpp や Ollama のバグなのか不明ですが、どうにか解決できると日本語メインの方には強力なモデルだと思います。

こうなってしまったら他のアプリを落としたりして空きメモリを増やそう

タイムリーな情報として今日 Dell の情報漏洩のニュースがあったので、適当に拾った英語の記事を和訳させてみました。Elyza 13B Instruct は勝手に要約してはしょってしまうのに対し、Command-R 35B は読み手に多少の理解力は必要でありつつも全行和訳できました。さらに続けて「以上の情報から私はどのような対応を取るべきでしょうか?」と聞いた答えはこちらです。すばらしい。

まずはDellからのメールに書かれている手順に従うことをお勧めします。 Dellは影響を受けたお客様向けに専用サポートセンターを設定しており、メールに電話番号が含まれています。 また、信用機関や金融機関にも連絡して状況を確認することをお勧めします。  

現時点でパスワード変更も良い考えです。 特に最近になってから、再利用していない場合や、強固いパスワードをお勧めします。 パスワードマネージャーを使うと、すべてのオンラインアカウントでユニークなパスワードを使いやすくなります。 その上で、メールやオンラインアカウントの監視を強化することをおすすめします。 疑わしい活動を見つけたら、直ちに銀行やクレジットカード会社にお知らせください。

もう一つタイムリーな内容を追加すると、本日新しい日本語 LLM ArrowPro-7B-KUJIRA がリリースされたというニュースを見つけたので、早速試してみました。7B モデルなので、量子化せず f16 のままでも十分小さく速く、日本語の精度も高いです。チャットを楽しむ分には結構良い感じです。調子に乗って上記 Dell の英文ニュースの日本語訳も試してみました。結果は、まぁ悪くは無いです。ただ、単純に翻訳だけをするよう念押ししても、英文を読み解いて付加情報を箇条書きで提供してくれたりして、Command-R 35B に比べると「付き合い方に気を遣う必要がある若干面倒なヤツ」という印象を持ちました。もしかしたら System プロンプトなどの調整で良くなる可能性はあるかもしれません。他には Swallow も試しましたが、こちらは実用的なレベルではありませんでした。

実行環境

LLM の実行環境としてはこれらを使っています。上から使っている時間の長い順です。

目的アプリ名称タイプ理由、特徴
チャット / API サーバOllamaCLI, API サーバ一番メモリの負荷が小さく、動作も速い。日本語 LLM は表示がおかしくなる不具合があったが、Ollama 0.1.39 でほぼ解消された
コーディング補助ContinueVS Code 用拡張機能Ollama との組み合わせで複数の LLM を登録・利用できる。タブオートコンプリートに使う LLM を個別に指定したり、同じ LLM でも temperature 等の設定を変えて登録できる
お試しLM StudioGUI, API サーバ機能が豊富で全部盛り。自分の RAM で使えそうな LLM を探しやすい。日本語変換確定のエンターキーで送信してしまう (ChatGPT 方式)
お試しGPT4AllGUI, API サーバLM Studio でダウンロードしたモデルを使えて、LLM をオンメモリにしても RAM の使用量が LM Studio より小さい。日本語モデルとの相性が良い気がする
リンク先は、本家のサイト

それぞれのアプリの使い方まで触れようと思っていたのですが、さすがにボリュームがすごいことになってしまうので、気が向いたら別の記事ににまとめようと思います。全て有名どころなので、探せば日本語での情報もたくさん見つかるでしょう。とりあえず全部インストールしていじってみることをお勧めします。それぞれに良いところがあり、全ての機能や特徴をカバーできているアプリはありません。今回は Ollama については触れておこうと思います。

Ollama で使えるモデルをダウンロードする方法

Ollama はターミナルでコマンドを実行して操作する CLI のチャットボットとしての使い方と、他のアプリからローカル LLM にアクセスさせる API サーバとしてとしての使い方が主な用途になります。Ollama で使えるモデルの入手は以下の方法があります:

  1. 対応済みモデルを Ollama のサイトで確認し、コマンドでダウンロード
  2. 未対応のモデルを Hugging Face から直接、もしくは LM Studio 等でダウンロードし、変換

どちらもコツや手順が必要なので、紹介しておきます。

Ollama のサイトで探す

上で紹介した Command-R 35B Q4_0 をダウンロードする手順です。サポート済みモデルが存在していれば、概ね同様の手順でダウンロードできます。ollamaコマンドを実行するので、事前に Ollama のインストールと実行をしておいてください。

メニューバーにラマがいれば実行中

(1) Ollama のサイトにアクセスし、上部の Search models にモデル名を入力して探す

“command-r” をクリック

(2) ドロップダウンメニューの、View all tags をクリック

ここにすでによさげなサイズのものがあればそれをクリックでも OK

(3) 量子化とサイズのちょうどよさげなものに狙いを付けて、クリック

20GB なら、32GB RAM でイケるハズ (たまたまこの例では上 4つは同じもの)

(4) 先ほどのドロップダウンの右にダウンロードコマンドが表示されるので、その右にあるアイコンをクリックしてコピー

右のボタンでコマンドをコピー

(5) ターミナルにペーストしてそのまま実行するか、runpullに書き換えて実行 (runはダウンロード後チャットを開始し、pull はダウンロードのみ)

# ダウンロード後すぐチャットするなら:
ollama run command-r:35b-v0.1-q4_0

# ダウンロードだけしておくなら:
ollama pull command-r:35b-v0.1-q4_0

Hugging Face からダウンロードして、変換・インストールする

ちょうど良いので、上で紹介した日本語 LLM ArrowPro-7B-KUJIRA での手順です。Ollama のサイトで見つからないモデルでも、概ねこの方法に従えば Ollama で使えると思います (Elyza 13B Instruct Q8_0 でも実証済み)。ざっくり、Transformer フォーマットを GGUF に変換し、モデルファイルを作って読み込ませる、という流れです。GGUF フォーマットのモデルが入手できるなら、手順 (7) の次、(オプション) 以降を実行してください。

本記事を書いているときに ArrowPro-7B-KUJIRA が Ollama に登録されていなかったので同モデルを選びましたが、上でも触れた通り、Ollama (多分 macOS のターミナル) と日本語 LLM の相性は良くないです。出力内容が途中で消えたり、(ハルシネーションではなく) 同じ文章が 2回表示されたりします。特に、改行がない長い文章の時に発生しがちです。デリートキーで文字を消すときも、どこまで消えたかよくわかりません。なので、日本語 LLM を使うときは、GPT4All をお勧めします。 (2024/05/30 更新) Ollama の日本語を含む全角文字の不具合は、0.1.39 でほぼ解決したようです (Discord)。ArrowPro-7B-KUJIRA で試した限り、打ち消した部分にあった不具合は発生していません。ただ、現在行末での改行時に1文字分消えてしまう様なので、Issue を報告しました。→ (2024/06/02) この不具合は Ollama 0.1.40 で修正されました。

(1) Hugging Face の開発元のカードを開く (この場合は DataPilot が本家)

一番上が正解。すでに gguf 版をあげてる人もいますが、見なかったことに

(2) 右の方の三点リーダから、Clone repository をクリック

(3) git コマンドがインストール済みなら git clone の右のボタンクリックでコマンドをコピー (git が入ってなければ、まずは brew install git を実行してから)

(4) 書類フォルダにLLMフォルダでも作り、その中でコピーしたgit cloneコマンドを実行し、モデルのダウンロード開始

mkdir ~/Documents/LLM
cd ~/Documents/LLM
git clone https://huggingface.co/DataPilot/ArrowPro-7B-KUJIRA

(5) ダウンロードを待っている間に、こちら↓の npaka 様サイトの手順に従い、llama.cpp をインストール (1. Llama.cpp のインストールの、 (1)~(4) まで)

llama.cpp による transformersモデル の量子化

(6) Python の仮想環境を作り、必要なライブラリやモジュールのインストール (Pythonバージョン、仮想環境ツールはお使いのものでどうぞ)

pipenv --python 3.11
pipenv shell
pip install -r requirements.txt

※ 以降は、モデルのダウンロードが終わってから実行してください。

(7) llama.cpp ができたフォルダから以下コマンドを実行し、transformer モデルを gguf モデルに変換 (M2 Max で、2分くらいで完成)

python convert.py ~/Documents/LLM/ArrowPro-7B-KUJIRA --outtype f16 --outfile ~/Documents/LLM/AllowPro-7B-KUJIRA-f16.gguf

(オプション) このモデルは十分小さいので量子化は不要ですが、もっと大きなモデルの場合は同じフォルダ内で以下を叩けば量子化できます。また、gguf フォーマットは LM Studio や GPT4All で使えます。

# 8bit 量子化の場合:
./quantize ~/Documents/LLM/AllowPro-7B-KUJIRA-f16.gguf ~/Documents/LLM/AllowPro-7B-KUJIRA-f16-Q8_0.gguf Q8_0

# 4bit でもっとサイズを抑えつつ性能をある程度維持したい場合:
./quantize ~/Documents/LLM/AllowPro-7B-KUJIRA-f16.gguf ~/Documents/LLM/AllowPro-7B-KUJIRA-f16-Q8_0.gguf Q4_K_M

(8) Ollama 用モデルファイルを作る

FROM には gguf 変換済みのファイルを指定します。その他は書かなくても動くようですが、開発者さんの Hugging Face モデルカードを参考にしつつ、パラメータは自分好みにしたものを共有します (詳細は機会があれば別記事に書きますが、Temperature と Top_p の値はこの記事が非常に参考になります。Ollama のモデルファイルで指定できる内容はこちらの公式に書かれています)。ArrowPro-7B-KUJIRA であれば結構良い感じだったので、まずはそのまま使っても良いと思います。

FROM ~/Documents/LLM/AllowPro-7B-KUJIRA-f16.gguf

PARAMETER temperature 0.7
PARAMETER top_p 0.8
PARAMETER top_k 10

SYSTEM """
あなたは日本語を話す優秀なアシスタントです。回答には必ず日本語で答えてください。
"""

TEMPLATE """[INST] <<SYS>>{{ .System }}<</SYS>>

{{ .Prompt }} [/INST]
"""

(9) 以下コマンドで Ollama にインストール

create の次の文字列は、自分がわかる名前であれば何でもかまいません。-f の後には上で作ったモデルファイルを指定します。諸々問題無ければ、2分ほどで終わります。

ollama create ArrowPro-7B-KUJIRA-f16.gguf:converted -f ./KUJIRA-ModelFile

Ollama でのチャットの使い方 (ざっくり紹介)

モデルの一覧確認:

ollama list
# または
ollama ls

# 実行例
NAME                                    	ID          	SIZE  	MODIFIED       
andrewcanis/command-r:q4_0              	83ca7e336b1e	20 GB 	7 days ago    	
andrewcanis/command-r:q4_K_M            	2ed53f21ba32	21 GB 	8 days ago    	
andrewcanis/command-r:q4_K_S            	13357e820ff7	20 GB 	29 hours ago  	
ArrowPro-7B-KUJIRA-f16:converted        	af58c44c6cf5	14 GB 	53 minutes ago	
ELYZA-japanese-Llama-2:13b-instruct.Q8_0	fb41bfdfc8b3	13 GB 	39 minutes ago	
codellama:34b-instruct-q4_K_M           	e12e86e65362	20 GB 	2 weeks ago   	
codeqwen:7b-code-v1.5-q8_0              	f076b41b0d2e	7.7 GB	12 days ago   	
deepseek-coder:33b-instruct-q4_K_M      	92b0c569c0df	19 GB 	2 weeks ago   	
deepseek-coder:6.7b-instruct-q8_0       	54b58e32d587	7.2 GB	12 days ago   	
llama3:8b-instruct-q8_0                 	5a511385d20f	8.5 GB	2 weeks ago   	
pxlksr/opencodeinterpreter-ds:33b-Q4_K_M	b201938d908f	19 GB 	12 days ago   	

不要なモデルの削除:

ollama rm (モデル名)

# 実行例
ollama rm ELYZA-japanese-Llama-2:13b-instruct.Q8_0

モデルを選択したチャットの実行:

ollama run (モデル名)

# 実行例
ollama run ArrowPro-7B-KUJIRA-f16.gguf:converted

(チャット中) コマンド一覧:

/?

# 設定できる内容の表示
/? /set

(チャット中) 表示できる各種情報の一覧:

/show

# 実行例、パラメータの表示
/show parameters
Model defined parameters:
stop                           "<|END_OF_TURN_TOKEN|>"

(チャット中) パラメータの設定:

/set parameter 項目 値

# 設定例
/set parameter top_p 0.8
Set parameter 'top_p' to '0.8'

(チャット中) これまでのチャットの内容をリセットして新たな話題を始める:

/clear

(チャット中) LLM のテキスト生成を止めるキーボードショートカット:

Control + C

(チャット中) チャットの終了:

/bye

まとまらない

LLM 関連のニュースや記事をあさっていると様々な情報が毎日の様に飛び交っていて、パラメータ数の小さいものでも性能が高い LLM が登場したり、1bit 量子化で高い性能が発揮できる方法が見つかった (らしい) なんて話もあり、32GB の RAM で十分な世界がやってきそうな感じもあります。Apple は M4 チップをリリースし、来年には Mac にも乗っかってくるでしょう。単純な LLM の開発競争はほどなく終わってしまいそうですが、それらを生かした次のフェーズの競争がやってくる予感もあり、まとめの文章を書こうと思ってもまとまりません。なので、また何か書きます。

Image by Stable Diffusion

realisticVision の新しいモデルが出ていたので使ってみました。キレイですね。クジラがらみの LLM が 2つ登場したのもあり、今回はこんなイメージです。

Date:
2024年5月11日 4:24:52

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
realistic, whale in a swimming pool, swimming with kids

Exclude from Image:

Seed:
184094467

Steps:
35

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

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

© Peddals.com