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点です。
- GUI は PySide6 を使う(tkinter は tcl-tk の罠がある)
-
freeze_support()を忘れない(multiprocessing の二重起動対策) - モデルは同梱する(初回ダウンロード待ちをなくせる)
同じところでハマっている方の役に立てば。