その他

ローカルLLMで音声文字起こしアプリを作る【Python編①】環境構築からアプリ化まで

その他
この記事は約26分で読めます。

はじめに

みなさん、こんにちは。株式会社インプルのyotaです。

さっそくですが、

「キーボードで打つより、しゃべった方が早い」

そう感じたことはありませんか?

一般的な日本語のタイピング速度の平均は1分間に60〜80文字程度(かな漢字変換含む)です。実務レベルでは100〜150文字/分が最低ライン、200〜300文字/分で「速い(事務・エンジニア職目安)」と評価されます。

一方で、人間の発話速度は平均で1分間に300~400文字程度と言われており、キーボード入力を圧倒するスピードが出ます。

この記事では、マイクから声を拾い、ローカルLLMを使ってリアルタイムに文字起こし・補正するアプリをPythonで作る過程を紹介します。クラウドAPIは一切使わず、全てローカルで完結します。

使用技術

役割ツールライセンス
音声認識(STT)faster-whisper + Whisper large-v3-turboMIT
LLM補正Ollama + Qwen3:8bMIT / Apache 2.0
マイク入力sounddeviceBSD-3
GUICustomTkinterMIT

動作環境

  • MacBook Pro M2 16GB(macOS)
  • Python3.12
  • Homebrew インストール済み

技術選定の背景

STT:faster-whisperを選んだ理由

OpenAI Whisperには複数の実装がありますが、今回はfaster-whisperを選びました。

  • OpenAI Whisper と同等の精度を維持しつつ、最大4倍高速
  • int8 量子化でメモリ使用量を削減
  • CPU/GPU 両対応で M2 Mac でも動作

モデルはWhisper large-v3-turboを使用します。large-v3比で8倍高速でありながら99言語以上を同制度でサポートするモデルです。

M2 Macでの注意点

faster-whisper が依存する CTranslate2 は Apple の MPS(Metal)に現時点では非対応です。CPU で動作しますが、実用的な速度は出ます。

LLM:Ollama + Qwen3:8bを選んだ理由

音声認識の結果をそのまま使うと、句読点がない・誤変換がある・ひらがなが混ざるなどの問題があります。そこでローカルLLMで補正をかけます。

  • Ollama:MITライセンス、OpenAI互換API、登録不要
  • Qwen3:8b:Apache2.0、日本語対応、M2 16GBで安定動作

環境構築

Ollamaのインストールとモデルのダウンロード

# Homebrew でインストール
brew install ollama

# サーバー起動(ターミナルAで起動したまま維持)
ollama serve

# 別ターミナルでモデルをダウンロード(約5.2GB)
ollama pull qwen3:8b

# 動作確認
ollama run qwen3:8b "日本語で短く挨拶してください。"

Python プロジェクトのセットアップ

# プロジェクト作成
mkdir -p ~/projects/voice-transcriber
cd ~/projects/voice-transcriber

# 仮想環境
python3 -m venv .venv
source .venv/bin/activate

# パッケージインストール
pip install faster-whisper sounddevice numpy ollama

# 確認
pip list | grep -E "faster|sounddevice|numpy|ollama"

以下のように表示されればOKです。

faster-whisper    1.2.1
numpy             2.4.4
ollama            0.6.2
sounddevice       0.5.5

sounddeviceのインストールでエラーが出た場合

PortAudioが必要です。.venvの外で以下を実行してください。

brew install portaudio

STEP1:STT単体検証

まず音声認識だけを単独で動かします。src/stt.pyを作成します。

# src/stt.py
import numpy as np
import sounddevice as sd
from faster_whisper import WhisperModel

MODEL_SIZE        = "large-v3-turbo"
DEVICE            = "cpu"
COMPUTE_TYPE      = "int8"
SAMPLE_RATE       = 16000
CHANNELS          = 1
SILENCE_THRESHOLD = 0.003  # 環境に合わせて調整

def load_model() -> WhisperModel:
    print(f"[STT] モデルをロード中: {MODEL_SIZE} ...")
    model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)
    print("[STT] モデルのロード完了")
    return model

def transcribe_microphone(model: WhisperModel, duration_sec: int = 5) -> str:
    print(f"[STT] 録音開始({duration_sec}秒)... 話してください")
    audio = sd.rec(
        int(duration_sec * SAMPLE_RATE),
        samplerate=SAMPLE_RATE,
        channels=CHANNELS,
        dtype="float32",
    )
    sd.wait()
    print("[STT] 録音完了、文字起こし中...")

    rms = np.sqrt(np.mean(audio ** 2))
    if rms < SILENCE_THRESHOLD:
        print("[STT] 音声が検出されませんでした(無音)")
        return ""

    audio_flat = audio.flatten()
    segments, info = model.transcribe(
        audio_flat,
        language="ja",
        beam_size=5,
        vad_filter=True,
        vad_parameters={"min_silence_duration_ms": 500},
    )
    return "".join(seg.text for seg in segments).strip()

if __name__ == "__main__":
    model = load_model()
    result = transcribe_microphone(model, duration_sec=5)
    print(f"[結果] {result if result else '(音声なし)'}")
# src/ ディレクトリから実行
cd src
python stt.py

ハマりどころ:無音判定の閾値

初回実行時に「音声が検出されませんでした」と出る場合は、SILENCE_THRESHOLDがお使いの環境に合っていない可能性があります。以下のスクリプトで実際のRMS値を計測してみてください。

python - <<'EOF'
import sounddevice as sd
import numpy as np

SAMPLE_RATE = 16000

print("=== 無音時のRMS計測(5秒待つ) ===")
audio = sd.rec(int(5 * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=1, dtype="float32")
sd.wait()
rms_silent = np.sqrt(np.mean(audio ** 2))
print(f"無音RMS: {rms_silent:.5f}")

print("=== 発話時のRMS計測(5秒話す) ===")
audio = sd.rec(int(5 * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=1, dtype="float32")
sd.wait()
rms_voice = np.sqrt(np.mean(audio ** 2))
print(f"発話RMS: {rms_voice:.5f}")
print(f"推奨SILENCE_THRESHOLD: {(rms_silent + rms_voice) / 2:.5f}")
EOF

私の環境では無音 RMS が 0.00085、発話 RMS が 0.00474 でした。デフォルト値の0.01が発話RMSよりも大きかったため、声を出していても無音と判定されていました。SILENCE_THRESHOLD = 0.003 程度に設定すると安定しました。

STEP2:LLM補正モジュール

音声認識結果をそのまま使うと「句読点なし」「ひらがな混じり」などの問題があります。Ollama + Qwen3:8bで補正します。src/llm.pyを作成してください。

# src/llm.py
from ollama import Client

OLLAMA_HOST = "http://localhost:11434"
MODEL_NAME  = "qwen3:8b"

SYSTEM_PROMPT = """あなたは音声文字起こしの校正専門家です。
入力は音声認識エンジンが出力した生テキストです。以下のルールに従って補正してください。

【必須処理】
1. ひらがなで書かれた語は適切な漢字・カタカナに変換する(例:かいぎ→会議、ごご→午後)
2. 音声認識の誤変換を文脈から推測して修正する
3. 文末には必ず句点(。)を付ける。文中の区切りには読点(、)を挿入する。
4. 数字は適切な表記に変換する(例:さんじ→3時)

【禁止事項】
- 文体・敬語レベルの変更
- 内容の追加・削除・要約
- 説明やコメントの付加

【出力形式】
補正後のテキストのみを出力する。それ以外は何も出力しない。"""

def load_client() -> Client:
    client = Client(host=OLLAMA_HOST)
    client.list()
    print(f"[LLM] 接続OK、モデル: {MODEL_NAME}")
    return client

def correct_text(client: Client, raw_text: str) -> str:
    if not raw_text.strip():
        return ""
    response = client.chat(
        model=MODEL_NAME,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user",   "content": raw_text},
        ],
        options={"temperature": 0.1, "num_predict": 512},
        think=False,
    )
    return response.message.content.strip()

プロンプトのチューニング

最初のプロンプトでは「ひらがな→漢字変換」や「誤変換修正」がうまく機能しませんでした。

# 初期プロンプト(失敗例)
【入力】かいぎはごごさんじからですよろしくおねがいします
【補正】かいぎはごごさんじからです。よろしくおねがいします。
→ 句読点は入ったが漢字変換されていない

試行錯誤の末に「必須処理」「禁止事項」を明示する形式にしたところ安定しました。

# 改善後
【入力】かいぎはごごさんじからですよろしくおねがいします
【補正】会議は午後3時からです。よろしくお願いします。

STEP3:パイプライン結合

STTとLLMをつないで1つのパイプラインにします。src/pipeline.pyを作成します。

# src/pipeline.py
import time
from stt import load_model, transcribe_microphone
from llm import load_client, correct_text

RECORD_SEC = 5

def run_pipeline(model, client, duration_sec: int = RECORD_SEC) -> dict:
    total_start = time.perf_counter()

    stt_start = time.perf_counter()
    raw = transcribe_microphone(model, duration_sec=duration_sec)
    stt_sec = time.perf_counter() - stt_start

    if not raw:
        return {"raw": "", "corrected": "", "stt_sec": stt_sec,
                "llm_sec": 0.0, "total_sec": time.perf_counter() - total_start}

    llm_start = time.perf_counter()
    corrected = correct_text(client, raw)
    llm_sec = time.perf_counter() - llm_start

    return {
        "raw": raw, "corrected": corrected,
        "stt_sec": stt_sec, "llm_sec": llm_sec,
        "total_sec": time.perf_counter() - total_start,
    }

if __name__ == "__main__":
    model  = load_model()
    client = load_client()

    while True:
        key = input(">>> Enterで録音開始 / q で終了: ").strip().lower()
        if key == "q":
            break
        result = run_pipeline(model, client)
        if not result["raw"]:
            print("(音声が検出されませんでした)")
            continue
        print(f"  [STT] {result['raw']}")
        print(f"  [LLM] {result['corrected']}")
        print(f"  [時間] STT:{result['stt_sec']:.1f}s LLM:{result['llm_sec']:.1f}s")
# src/ ディレクトリから実行
cd src
python pipeline.py

実行結果の例は次のようになっています。

[STT] 今日風強くない?マジ寒いんだけど
[LLM] 今日風強くない?マジ寒いんだけど。
[時間] STT:11.5s LLM:0.7s

LLMの処理は0.7秒と高速です。STTの11.5秒のうち5秒は録音時間なので、実質の文字起こし処理は約6.5秒です。これがM2 MacのCPU動作での限界値です。

LLMの限界について

以下のようなケースでLLMが過剰補正することがありました。

STT出力: 今日の天気はあれです。散歩日和ですね。
LLM補正: 今日の天気は良いです。散歩日和ですね。

「あれ」をLLMが文脈から「良い」と書き換えてしまっています。STTが誤認識した結果をLLMが「正しい入力」として処理するため、このような二重誤りが発生することがありました。(本当は「晴れ」と言いたかったのですが発音が悪いのか、音の拾いが悪いのか…

一方、以下のケースではLLMの真価が発揮されました。

STT出力: 我輩は猫である名前はまだないどこで生まれたかとんと見当がつかぬ
LLM補正: 我輩は猫である。名前はまだない。どこで生まれたか、とんと見当がつかぬ。

句読点のない連続テキストへの句読点挿入は非常に正確でした。

STEP4:GUIアプリ化

最後にCustomTinterでGUIを作ります。

tkinterのインストール確認

python -c "import tkinter; print(tkinter.TkVersion)"

ModuleNotFoundError が出た場合は以下を実行してください(.venv の外で)。

brew install python-tk@3.14

アプリの作成

src/app.pyを作成します。録音秒数をスライダーで設定し、ボタンを押すと録音・文字起こし・LLM補正が実行される2ペイン構成のアプリです。

# src/app.py
import threading
import tkinter as tk
import customtkinter as ctk

from stt import load_model, transcribe_microphone
from llm import load_client, correct_text

ctk.set_appearance_mode("system")
ctk.set_default_color_theme("blue")

RECORD_SEC = 5
WINDOW_W   = 720
WINDOW_H   = 560

class VoiceTranscriberApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("Voice Transcriber")
        self.geometry(f"{WINDOW_W}x{WINDOW_H}")
        self.resizable(True, True)
        self.model        = None
        self.client       = None
        self.is_recording = False
        self._build_ui()
        self._start_model_loading()

    def _build_ui(self):
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(1, weight=1)
        self.grid_rowconfigure(2, weight=1)

        # ヘッダー
        header = ctk.CTkFrame(self, height=50, corner_radius=0)
        header.grid(row=0, column=0, sticky="ew")
        header.grid_columnconfigure(0, weight=1)
        ctk.CTkLabel(header, text="🎤 Voice Transcriber",
                     font=ctk.CTkFont(size=18, weight="bold")
                     ).grid(row=0, column=0, padx=20, pady=12, sticky="w")
        self.status_var = tk.StringVar(value="モデルを読み込み中...")
        ctk.CTkLabel(header, textvariable=self.status_var,
                     font=ctk.CTkFont(size=12), text_color="gray"
                     ).grid(row=0, column=1, padx=20, pady=12, sticky="e")

        # STT出力エリア
        frame_stt = ctk.CTkFrame(self)
        frame_stt.grid(row=1, column=0, sticky="nsew", padx=16, pady=(16, 8))
        frame_stt.grid_columnconfigure(0, weight=1)
        frame_stt.grid_rowconfigure(1, weight=1)
        ctk.CTkLabel(frame_stt, text="音声認識(生テキスト)",
                     font=ctk.CTkFont(size=13, weight="bold")
                     ).grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")
        self.stt_box = ctk.CTkTextbox(frame_stt, font=ctk.CTkFont(size=13), wrap="word")
        self.stt_box.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))

        # LLM補正出力エリア
        frame_llm = ctk.CTkFrame(self)
        frame_llm.grid(row=2, column=0, sticky="nsew", padx=16, pady=(8, 8))
        frame_llm.grid_columnconfigure(0, weight=1)
        frame_llm.grid_rowconfigure(1, weight=1)
        ctk.CTkLabel(frame_llm, text="LLM補正後テキスト",
                     font=ctk.CTkFont(size=13, weight="bold")
                     ).grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")
        self.llm_box = ctk.CTkTextbox(frame_llm, font=ctk.CTkFont(size=13), wrap="word")
        self.llm_box.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))

        # フッター
        footer = ctk.CTkFrame(self, height=64, corner_radius=0)
        footer.grid(row=3, column=0, sticky="ew")
        footer.grid_columnconfigure(1, weight=1)
        ctk.CTkLabel(footer, text="録音時間:",
                     font=ctk.CTkFont(size=12)
                     ).grid(row=0, column=0, padx=(16, 4), pady=16)
        self.duration_var = tk.IntVar(value=RECORD_SEC)
        self.duration_label = ctk.CTkLabel(footer, text=f"{RECORD_SEC}秒",
                                            font=ctk.CTkFont(size=12), width=36)
        self.duration_label.grid(row=0, column=2, padx=(0, 8), pady=16)
        self.slider = ctk.CTkSlider(footer, from_=3, to=15, number_of_steps=12,
                                     variable=self.duration_var, command=self._on_slider)
        self.slider.grid(row=0, column=1, padx=4, pady=16, sticky="ew")
        self.clear_btn = ctk.CTkButton(footer, text="クリア", width=80,
                                        fg_color="gray40", hover_color="gray30",
                                        command=self._clear_all)
        self.clear_btn.grid(row=0, column=3, padx=8, pady=16)
        self.record_btn = ctk.CTkButton(footer, text="⏺  録音開始", width=140,
                                         font=ctk.CTkFont(size=14, weight="bold"),
                                         state="disabled", command=self._on_record)
        self.record_btn.grid(row=0, column=4, padx=(0, 16), pady=16)

    def _start_model_loading(self):
        def _load():
            try:
                self._set_status("STTモデルをロード中...")
                self.model = load_model()
                self._set_status("LLMクライアントを初期化中...")
                self.client = load_client()
                self._set_status("準備完了 ✅")
                self.after(0, lambda: self.record_btn.configure(state="normal"))
            except Exception as e:
                self._set_status(f"初期化エラー: {e}")
        threading.Thread(target=_load, daemon=True).start()

    def _on_record(self):
        if self.is_recording:
            return
        self.is_recording = True
        self.record_btn.configure(state="disabled", text="⏺  録音中...")
        duration = self.duration_var.get()
        threading.Thread(target=self._run_pipeline, args=(duration,), daemon=True).start()

    def _run_pipeline(self, duration: int):
        try:
            self._set_status(f"録音中({duration}秒)...")
            raw = transcribe_microphone(self.model, duration_sec=duration)
            if not raw:
                self._set_status("音声が検出されませんでした")
                return
            self._append_text(self.stt_box, raw)
            self._set_status("LLMで補正中...")
            corrected = correct_text(self.client, raw)
            self._append_text(self.llm_box, corrected)
            self._set_status("完了 ✅")
        except Exception as e:
            self._set_status(f"エラー: {e}")
        finally:
            self.is_recording = False
            self.record_btn.configure(state="normal", text="⏺  録音開始")

    def _append_text(self, box, text):
        def _update():
            box.configure(state="normal")
            current = box.get("1.0", "end-1c")
            box.insert("end", ("\n" if current.strip() else "") + text)
            box.see("end")
        self.after(0, _update)

    def _set_status(self, msg):
        self.after(0, lambda: self.status_var.set(msg))

    def _clear_all(self):
        for box in (self.stt_box, self.llm_box):
            box.configure(state="normal")
            box.delete("1.0", "end")
        self._set_status("準備完了 ✅")

    def _on_slider(self, val):
        self.after(0, lambda: self.duration_label.configure(text=f"{int(val)}秒"))

if __name__ == "__main__":
    app = VoiceTranscriberApp()
    app.mainloop()
# src/ ディレクトリから実行
cd src
python app.py

起動後「準備完了 ✅」が表示されたら録音ボタンが押せるようになります。スライダーで録音秒数を設定してボタンを押すと、指定秒数後に自動的に文字起こしと LLM 補正が実行されます。

動作確認

以下の文章をゆっくり読み上げてテストしてみました。

  • 入力
    我輩は猫である名前はまだないどこで生まれたかとんと見当がつかぬ何でも薄暗いジメジメしたところでにゃーにゃー泣いていたことだけは記憶している
  • LLM補正後
    我輩は猫である。名前はまだない。どこで生まれたか、とんと見当がつかぬ。何でも薄暗いジメジメしたところで、にゃーにゃー泣いていたことだけは記憶している。

句読点ゼロのテキストに対して、LLMが正確に句読点を挿入できています

まとめ

この記事では以下を実装しました。

  • faster-whisper による音声認識(STT)
  • Ollama + Qwen3:8b による LLM 補正
  • スライダー付き GUI アプリ(固定秒数で録音・認識・補正)

処理時間は録音5秒分でSTT約6.5秒、LLM約0.7秒です。STTがボトルネックになっています。

一方、使ってみると「録音中に声が拾えているか分からない」「録音を止めるまで結果が見えないのが不安」という不満が出てきました。

次の記事ではリアルタイム変換への挑戦と、その限界、そして音量メーター付きの実用的なアプリへの改善を紹介します。