0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyInstaller + Faster-Whisper で音声文字起こし .app を作る:ハマりどころ全まとめ

0
Last updated at Posted at 2026-05-15

hoge_kawamuro さんの記事 を参考に、Claude Code と Obsidian を使って執筆しました。

はじめに

チームのメンバーに音声文字起こしツールを渡したい、でも「Python を入れてください」とは言いたくない——そんな動機で、Faster-Whisper を使った macOS アプリを PyInstaller でパッケージ化することにしました。

「ビルドするだけでしょ」と思っていたのですが、実際には tkinter の破損・tcl-tk バージョン不整合・multiprocessing の罠・モデルの同梱 など、いくつも詰まりどころがありました。この記事ではそれらをまとめて紹介します。

構成

最終的な技術スタックです。

役割 採用ライブラリ
音声認識 faster-whisper(CTranslate2ベース)
GUI PySide6(Qt)
パッケージング PyInstaller
対象OS macOS

課題:Python 不要で渡せる .app を作りたい

当初の要件はシンプルでした。

  • 音声・動画ファイルを読み込んで文字起こしできる
  • GPU があれば GPU を使い、なければ CPU にフォールバック
  • Python 環境なしで動く .app 形式で配布

実装手順

1. 最初の構成(tkinter + faster-whisper)

まず tkinter で GUI を作り、PyInstaller でビルドしました。

pip install faster-whisper pyinstaller
pyinstaller whisper_app.spec --noconfirm --clean

.spec ファイルで console=False を指定してコンソールウィンドウを非表示にします。

# whisper_app.spec(抜粋)
a = Analysis(['main.py'], ...)
exe = EXE(a.scripts, console=False, ...)
app = BUNDLE(exe, name='WhisperTranscriber.app', ...)

2. PySide6 への移行

後述するハマりどころ①②の結果、GUI を PySide6(Qt) に切り替えました。

pip install PySide6

PySide6 版の GUI 骨格:

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QVBoxLayout, QPushButton, QComboBox, QLabel
)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("WhisperTranscriber")
        self._build_ui()

    def _build_ui(self):
        layout = QVBoxLayout()

        self.model_combo = QComboBox()
        self.model_combo.addItems(["tiny", "base", "small", "medium"])
        self.model_combo.setCurrentText("base")

        self.device_combo = QComboBox()
        self.device_combo.addItems(["auto", "GPU", "CPU"])
        self.device_combo.setCurrentText("auto")

        self.start_btn = QPushButton("文字起こし開始")
        self.start_btn.clicked.connect(self.start_transcription)

        layout.addWidget(self.model_combo)
        layout.addWidget(self.device_combo)
        layout.addWidget(self.start_btn)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

3. CUDA / CPU 自動検出

デバイス選択は「auto」をデフォルトにし、起動時に検出結果をラベルで表示するだけにします。コンボボックスを書き換えると「auto がデフォルトにならない」バグが起きるためです。

def _detect_device(self) -> str:
    try:
        import torch
        if torch.cuda.is_available():
            self.device_label.setText("検出: GPU (CUDA)")
            return "cuda"
    except ImportError:
        pass
    self.device_label.setText("検出: CPU")
    return "cpu"

def _get_selected_device(self) -> str:
    selected = self.device_combo.currentText()
    if selected == "auto":
        return self._detect_device()
    elif selected == "GPU":
        return "cuda"
    else:
        return "cpu"

4. モデルの同梱

デフォルトの base モデルをあらかじめ .app に同梱して、初回起動時のダウンロードをなくします。

# main.py
import sys, os

def get_model_path(model_name: str) -> str:
    if getattr(sys, "frozen", False):
        # PyInstallerでバンドルされた場合
        base = sys._MEIPASS
        bundled = os.path.join(base, "models", model_name)
        if os.path.exists(bundled):
            return bundled
    return model_name  # 通常起動時はHuggingFaceキャッシュから

.spec にモデルディレクトリを追加:

# whisper_app.spec
a = Analysis(
    ['main.py'],
    datas=[
        ('models/base', 'models/base'),  # モデルを同梱
    ],
    ...
)

5. VAD による文節ベースの改行

faster-whisper のデフォルトはセグメント(数秒ごと)で区切られるため、出力が不自然な箇所で改行されます。無音の長さ で改行する方式に変更しました。

from faster_whisper import WhisperModel

def transcribe(audio_path: str, device: str, model_name: str) -> str:
    model = WhisperModel(
        get_model_path(model_name),
        device=device,
        compute_type="float16" if device == "cuda" else "int8",
    )

    segments, _ = model.transcribe(
        audio_path,
        language="ja",
        vad_filter=True,  # 無音区間を自動検出
    )

    SILENCE_THRESHOLD = 1.2  # 秒

    lines = []
    buffer = []
    prev_end = None

    for seg in segments:
        if prev_end is not None and (seg.start - prev_end) >= SILENCE_THRESHOLD:
            lines.append("".join(buffer))
            buffer = []
        buffer.append(seg.text)
        prev_end = seg.end

    if buffer:
        lines.append("".join(buffer))

    return "\n".join(lines)

ハマった点・注意点

① tkinter が Homebrew Python で壊れている

Homebrew でインストールした Python には python-tk が含まれていないため、PyInstaller のビルド時に以下の警告が出て tkinter が除外されます。

WARNING: tkinter installation is broken. It will be excluded from the application

brew install python-tk で解決できますが、さらに別の問題が待っていました。

② tcl-tk 9.0 と macOS の非互換

python-tk を入れてビルドし直すと .app が起動しない問題が発生しました。原因は Homebrew が入れた tcl-tk 9.0 と macOS の期待するバージョンの不整合です。

tcl-tk 8.6 に戻してリビルドする方法もありますが、根本解決として PySide6 に移行しました。PySide6 は tcl-tk に依存しないため、この問題が一切起きません。

③ multiprocessing でアプリが二重起動する

「文字起こし開始」ボタンを押すと .app がもう1つ起動してしまうバグがありました。

PyInstaller + macOS で multiprocessing を使う場合、子プロセスがメインスクリプトを再実行する既知の問題です。if __name__ == "__main__": の直後に freeze_support() を追加することで解決します。

from multiprocessing import freeze_support

if __name__ == "__main__":
    freeze_support()  # これが必須
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

④ デバイス検出がコンボボックスを上書きする

起動時に GPU/CPU を自動検出して setCurrentText() でコンボボックスを書き換えると、ユーザーが選択する前に「auto」以外に変わってしまいます。検出結果は 別途ラベルに表示するだけ にして、コンボボックスは触らないほうがシンプルです。

結果・まとめ

最終的な成果物:

  • dist/WhisperTranscriber.app(約 343MB)
  • base モデル(141MB)同梱済み
  • Python 不要、ダブルクリックで即起動

受け取った人は ZIP を解凍してダブルクリックするだけで使えます。

まとめると、PyInstaller で Whisper アプリを macOS 向けに配布するときのポイントは以下の3点です。

  1. GUI は PySide6 を使う(tkinter は tcl-tk の罠がある)
  2. freeze_support() を忘れない(multiprocessing の二重起動対策)
  3. モデルは同梱する(初回ダウンロード待ちをなくせる)

同じところでハマっている方の役に立てば。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?