ローカルLLM(Ollama)とVOICEVOXで作る、英日翻訳&読み上げデスクトップアプリ

Macで動くローカルLLM環境を活かして、「英語テキストを読み込んで日本語に翻訳し、結果を表示しながら音声で読み上げる」ツールを作ってみました。さらに会話を重ねる中で、Ollamaのモデル一覧からの選択、VOICEVOXのインストール手順確認、BlackHole経由の音声入力、録音中の逐次文字起こし表示まで、機能を段階的に拡張していきました。この記事ではその過程と最終的なソースコード全体をまとめます。

前提となる環境

  • Mac mini (Apple Silicon)
  • ローカルLLM実行環境として Ollama(30B程度までのモデルを想定)
  • 日本語音声合成エンジンとして VOICEVOX
  • 音声入力用の仮想オーディオデバイスとして BlackHole

1. まず全体構成を整理する

最初の相談は「Mac上のローカルLLMを使って、英語テキストファイルを日本語に翻訳し、結果を表示しつつ音声で読み上げるには何が必要か」というものでした。整理すると、必要な要素は次の4つに分解できます。

  1. 翻訳エンジン: Ollama + 多言語対応モデル(Qwen2.5-32B-Instruct、Gemma2-27B-itなど)
  2. ファイル読み込み&パイプライン制御: Pythonでテキストを読み込み、Ollama APIに投げる
  3. 結果表示: ターミナルでも良いが、GUIならStreamlit/GradioやPyQt6が候補
  4. 音声読み上げ: macOS標準のsayコマンド、またはより高品質な日本語特化TTSであるVOICEVOX

全体のデータフローは次のようになります。

テキストファイル
   → Python(チャンク分割)
   → Ollama API(翻訳: Qwen2.5-32B等)
   → 表示(ターミナル or GUI)
   → VOICEVOX API(音声合成)
   → afplay(再生)

2. PyQt6でGUIアプリの土台を作る

方向性が固まったところで、実際にPyQt6ベースのGUIアプリとして実装することにしました。要件は次の4点です。

  • ファイル選択ボタン+読み込みボタン
  • 翻訳ボタン
  • 翻訳結果のテキスト表示
  • 読み上げ開始ボタン(VOICEVOX利用)

翻訳はOllamaの/api/generateエンドポイントを、読み上げはVOICEVOXの/audio_query/synthesisという2段階のAPIを叩き、生成したwavをafplay(macOS標準コマンド)で再生する構成です。GUIをフリーズさせないよう、翻訳・音声合成はいずれもQThreadのワーカースレッドで非同期実行しています。

3. Ollamaのモデルをollama list相当で選べるようにする

最初のバージョンはモデル名をコード内に固定していましたが、「ollama listで取得した中から選択できるように」という要望を受けて、Ollamaの/api/tagsエンドポイント(ollama listと同じ情報源)を叩き、インストール済みモデルをプルダウンに動的表示する機能を追加しました。「一覧更新」ボタンで再取得もできるようにしています。

4. VOICEVOXのインストール手順

VOICEVOXは公式サイトからMac版をダウンロードし、.dmgからApplicationsフォルダにインストールします。Apple Silicon環境では初回起動時にRosettaのインストールを求められることがあります。また、開発元未検証の警告が出た場合は「システム設定」→「プライバシーとセキュリティ」から「このまま開く」を選択することで起動できます。インストール後にアプリを起動しておくと、バックグラウンドでAPIエンジン(デフォルトポート50021)が立ち上がり、GUIアプリからの読み上げリクエストを受け付けられる状態になります。

読み上げに使われるキャラクターは、GUI上の「話者」プルダウンで選択されたものです。起動時にVOICEVOXの/speakersエンドポイントから全キャラクター×スタイルの一覧を取得して表示しています。

5. BlackHole経由の音声入力機能を追加する

テキストファイルの読み込みに加えて、「MacのBlackHole経由の音声入力からテキストを生成する機能」を追加しました。構成は次の通りです。

  • sounddeviceで入力デバイス一覧を取得し、名前に”BlackHole”を含むデバイスを自動選択
  • 録音開始/停止をトグルボタンで制御し、sd.InputStreamのコールバックで音声フレームを蓄積
  • 録音停止後、ローカルのfaster-whispersmallモデル、int8量子化でCPU動作)を使って文字起こし
  • 文字起こし結果は「原文」テキストボックスに反映され、そのまま翻訳ボタンに繋がる

これにより、システム音声(動画や配信の英語音声など)をBlackHole経由でキャプチャし、テキスト化してから翻訳・読み上げまで一気通貫で行えるようになりました。あわせて、原文テキストボックスをファイル読み込み専用の読み取り専用から、文字起こし結果を手直しできる編集可能な仕様に変更しています。

6. 録音中にテキストを逐次表示する

最後の改善は「録音中にテキストを逐次表示する」というものです。faster-whisperは真のストリーミング認識には対応していないため、録音バッファを一定間隔(デフォルト5秒)で区切り、チャンクごとに個別に文字起こしして結果を順次追記表示する方式を採用しました。

  • 録音中、PARTIAL_CHUNK_INTERVAL_SEC秒ごとに未処理の音声チャンクを取り出して文字起こし
  • 結果を蓄積し、partial_resultシグナルで「これまでの全文」を原文欄に反映(カーソルは自動的に末尾へ)
  • 録音停止時には、残っている末尾チャンクも処理して最終テキストを確定

この方式にはチャンク境界で単語が分割されると認識精度が落ちるというトレードオフがありますが、待ち時間なく認識結果を確認できる体感の良さを優先しました。間隔を短くすれば反応は早くなる一方、モデルの推論負荷が増えるため、WHISPER_MODEL_SIZE(tiny/base/small/medium/large-v3)とのバランス調整が必要です。

最終的な機能一覧

  • 英語テキストファイルの選択・読み込み
  • BlackHole経由の音声入力からの逐次文字起こし(faster-whisper使用)
  • Ollamaにインストール済みのモデル一覧からの動的選択、日本語への翻訳
  • 翻訳結果の表示・編集
  • VOICEVOXの話者一覧からの選択、音声読み上げ

必要な依存パッケージ

pip install PyQt6 requests
pip install sounddevice numpy soundfile faster-whisper   # 音声入力機能用(任意)

事前に以下も準備しておく必要があります。

  • Ollama起動+翻訳用モデルのpull(例: ollama pull qwen2.5:32b
  • VOICEVOX ENGINEの起動(公式Macアプリ、デフォルトポート50021)
  • BlackHole (2ch) のインストールと、システム音声をBlackHole経由でキャプチャできるMulti-Output Deviceの設定

最終ソースコード全体

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
英語テキストファイル → Ollama(ローカルLLM)で日本語翻訳 → VOICEVOXで読み上げ
PyQt6 GUIアプリ

前提:
  - Ollama がローカルで起動しており、翻訳用モデルが1つ以上 pull 済みであること
      $ ollama pull qwen2.5:32b
      $ ollama serve   (通常はインストール時に常駐起動される)
    起動中のOllamaが持つモデル一覧 (`ollama list` 相当) はGUI上のプルダウンから選択可能
  - VOICEVOX ENGINE がローカルで起動していること (デフォルト http://localhost:50021)
      公式サイトからmacOS版アプリ or Dockerで起動しておく
  - (任意) 音声入力機能を使う場合:
      BlackHole (2ch) をインストールし、システム音声の出力先 or Multi-Output Device に
      含めておく (https://existential.audio/blackhole/)
      文字起こしはローカルのfaster-whisperを使用
  - 依存パッケージ:
      pip install PyQt6 requests
      pip install sounddevice numpy soundfile faster-whisper   # 音声入力機能用(任意)

実行:
  python translator_app.py
"""

import sys
import os
import time
import tempfile
import subprocess

import requests
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QTextEdit, QFileDialog, QLabel, QMessageBox,
    QComboBox, QSpinBox
)
from PyQt6.QtCore import QThread, pyqtSignal

# 音声入力(録音+文字起こし)関連は任意依存。未インストールでも他機能は動作させる。
try:
    import sounddevice as sd
    import numpy as np
    AUDIO_LIBS_AVAILABLE = True
except ImportError:
    AUDIO_LIBS_AVAILABLE = False

# ===================== 設定 =====================
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_URL = f"{OLLAMA_BASE_URL}/api/generate"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"
OLLAMA_MODEL_FALLBACK = "qwen2.5:32b"   # モデル一覧取得に失敗した場合のフォールバック

VOICEVOX_BASE_URL = "http://localhost:50021"
DEFAULT_SPEAKER_ID = 1   # 四国めたん(ノーマル)。VOICEVOXの /speakers で一覧取得可能

WHISPER_MODEL_SIZE = "small"   # tiny/base/small/medium/large-v3 (精度と速度のトレードオフ)
WHISPER_LANGUAGE = "en"        # 入力音声の言語。自動判定させたい場合は None に変更
PARTIAL_CHUNK_INTERVAL_SEC = 5.0  # 録音中、何秒ごとに区切って逐次文字起こしするか
# ==================================================

_whisper_model_cache = {}


def get_whisper_model(size: str):
    """faster-whisperモデルをロード(初回のみ)。以降はキャッシュを再利用する。"""
    from faster_whisper import WhisperModel
    if size not in _whisper_model_cache:
        _whisper_model_cache[size] = WhisperModel(size, device="cpu", compute_type="int8")
    return _whisper_model_cache[size]


class TranslateWorker(QThread):
    """Ollama APIで翻訳を実行するワーカースレッド"""
    finished = pyqtSignal(str)
    error = pyqtSignal(str)

    def __init__(self, text: str, model: str = OLLAMA_MODEL_FALLBACK):
        super().__init__()
        self.text = text
        self.model = model

    def run(self):
        if not self.model:
            self.error.emit("翻訳に使用するモデルが選択されていません。")
            return
        prompt = (
            "以下の英文を自然な日本語に翻訳してください。"
            "訳文のみを出力し、前置きや説明、注釈は一切付けないでください。\n\n"
            f"{self.text}"
        )
        try:
            resp = requests.post(
                OLLAMA_URL,
                json={
                    "model": self.model,
                    "prompt": prompt,
                    "stream": False,
                },
                timeout=600,
            )
            resp.raise_for_status()
            data = resp.json()
            translated = data.get("response", "").strip()
            if not translated:
                raise ValueError("翻訳結果が空でした。モデル名やOllamaの起動状態を確認してください。")
            self.finished.emit(translated)
        except requests.exceptions.ConnectionError:
            self.error.emit(
                "Ollamaに接続できません。`ollama serve` が起動しているか確認してください。"
            )
        except Exception as e:
            self.error.emit(str(e))


class TTSWorker(QThread):
    """VOICEVOX APIで音声合成し、afplayで再生するワーカースレッド"""
    finished = pyqtSignal()
    error = pyqtSignal(str)

    def __init__(self, text: str, speaker: int = DEFAULT_SPEAKER_ID):
        super().__init__()
        self.text = text
        self.speaker = speaker

    def run(self):
        temp_path = None
        try:
            # 1. audio_query: テキストから音声合成用パラメータを生成
            query_res = requests.post(
                f"{VOICEVOX_BASE_URL}/audio_query",
                params={"text": self.text, "speaker": self.speaker},
                timeout=60,
            )
            query_res.raise_for_status()
            query_json = query_res.json()

            # 2. synthesis: パラメータからwav音声データを生成
            synth_res = requests.post(
                f"{VOICEVOX_BASE_URL}/synthesis",
                params={"speaker": self.speaker},
                json=query_json,
                timeout=120,
            )
            synth_res.raise_for_status()
            wav_bytes = synth_res.content

            # 3. 一時ファイルに書き出してafplayで再生 (macOS標準コマンド)
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                f.write(wav_bytes)
                temp_path = f.name

            subprocess.run(["afplay", temp_path], check=True)
            self.finished.emit()

        except requests.exceptions.ConnectionError:
            self.error.emit(
                "VOICEVOX ENGINEに接続できません。アプリ/Dockerが起動しているか確認してください。"
            )
        except Exception as e:
            self.error.emit(str(e))
        finally:
            if temp_path and os.path.exists(temp_path):
                os.remove(temp_path)


class RecordTranscribeWorker(QThread):
    """
    BlackHole等の入力デバイスから音声を録音し、一定間隔ごとにチャンク単位で
    faster-whisperによる文字起こしを行って逐次表示できるワーカースレッド。
    録音の停止は stop_recording() を外部(メインスレッド)から呼んで行う。

    partial_result: チャンクごとの文字起こし結果を反映した「これまでの全文」を通知
    finished: 停止後、最後のチャンクまで処理し終えた最終テキストを通知
    """
    partial_result = pyqtSignal(str)
    finished = pyqtSignal(str)
    error = pyqtSignal(str)

    def __init__(self, device_index, channels: int = 2, samplerate: int = 44100,
                 model_size: str = WHISPER_MODEL_SIZE, language=WHISPER_LANGUAGE,
                 chunk_interval: float = PARTIAL_CHUNK_INTERVAL_SEC):
        super().__init__()
        self.device_index = device_index
        self.channels = channels
        self.samplerate = samplerate
        self.model_size = model_size
        self.language = language
        self.chunk_interval = chunk_interval
        self._frames = []
        self._processed_index = 0  # self._frames のうち、既に文字起こし済みの位置
        self._text_parts = []
        self._stream = None
        self._stop_flag = False

    def stop_recording(self):
        self._stop_flag = True

    def run(self):
        if not AUDIO_LIBS_AVAILABLE:
            self.error.emit(
                "音声入力に必要なライブラリがありません。"
                "`pip install sounddevice numpy soundfile faster-whisper` を実行してください。"
            )
            return

        try:
            def callback(indata, frames, time_info, status):
                self._frames.append(indata.copy())

            self._stream = sd.InputStream(
                device=self.device_index,
                channels=self.channels,
                samplerate=self.samplerate,
                callback=callback,
            )
            self._stream.start()

            last_chunk_time = time.time()
            while not self._stop_flag:
                self.msleep(200)
                if time.time() - last_chunk_time >= self.chunk_interval:
                    self._process_pending_chunk()
                    last_chunk_time = time.time()

            self._stream.stop()
            self._stream.close()

            # 停止後、まだ処理していない末尾の音声を最終チャンクとして処理
            self._process_pending_chunk()

            final_text = "".join(self._text_parts).strip()
            if not final_text:
                raise ValueError("文字起こし結果が空でした。録音内容/デバイスを確認してください。")

            self.finished.emit(final_text)

        except Exception as e:
            self.error.emit(str(e))

    def _process_pending_chunk(self):
        """前回処理位置以降にたまった音声フレームをまとめて文字起こしし、結果を通知する"""
        frames_snapshot = self._frames[self._processed_index:]
        self._processed_index = len(self._frames)
        if not frames_snapshot:
            return

        chunk = np.concatenate(frames_snapshot, axis=0)
        # 極端に短いチャンク(数百ms未満)は認識精度が低いためスキップし、次回に回す
        if chunk.shape[0] < int(self.samplerate * 0.3):
            self._processed_index -= len(frames_snapshot)
            return

        tmp_path = None
        try:
            import soundfile as sf
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                tmp_path = f.name
            sf.write(tmp_path, chunk, self.samplerate)

            model = get_whisper_model(self.model_size)
            segments, _info = model.transcribe(tmp_path, language=self.language)
            chunk_text = "".join(seg.text for seg in segments)

            if chunk_text:
                self._text_parts.append(chunk_text)
                self.partial_result.emit("".join(self._text_parts).strip())
        except Exception:
            # チャンク単位の失敗で録音全体を止めない。最終結果には反映されないが継続する。
            pass
        finally:
            if tmp_path and os.path.exists(tmp_path):
                os.remove(tmp_path)


class TranslatorApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("EN→JA 翻訳&読み上げ (Ollama + VOICEVOX)")
        self.resize(760, 640)
        self.selected_path = None
        self.original_text = ""
        self.speaker_map = {}  # 表示名 -> speaker_id
        self.audio_device_map = {}  # 表示名 -> device_index
        self.record_worker = None
        self.is_recording = False
        self.init_ui()
        self.load_speakers()
        self.load_ollama_models()
        self.load_audio_devices()

    # ---------------- UI構築 ----------------
    def init_ui(self):
        layout = QVBoxLayout()

        # ファイル選択行
        file_row = QHBoxLayout()
        self.file_label = QLabel("ファイル未選択")
        select_btn = QPushButton("ファイル選択")
        select_btn.clicked.connect(self.select_file)
        self.load_btn = QPushButton("読み込み")
        self.load_btn.clicked.connect(self.load_file)
        self.load_btn.setEnabled(False)
        file_row.addWidget(self.file_label, stretch=1)
        file_row.addWidget(select_btn)
        file_row.addWidget(self.load_btn)
        layout.addLayout(file_row)

        # 音声入力行 (BlackHole等から録音 → 文字起こし)
        audio_row = QHBoxLayout()
        audio_row.addWidget(QLabel("音声入力デバイス:"))
        self.audio_device_combo = QComboBox()
        audio_row.addWidget(self.audio_device_combo, stretch=1)
        self.refresh_devices_btn = QPushButton("デバイス更新")
        self.refresh_devices_btn.clicked.connect(self.load_audio_devices)
        audio_row.addWidget(self.refresh_devices_btn)
        self.record_btn = QPushButton("録音開始")
        self.record_btn.clicked.connect(self.toggle_recording)
        audio_row.addWidget(self.record_btn)
        layout.addLayout(audio_row)

        # 原文表示 (ファイル読み込み/音声文字起こし結果を表示。翻訳前に編集可)
        layout.addWidget(QLabel("原文 (英語 / 編集可):"))
        self.original_edit = QTextEdit()
        layout.addWidget(self.original_edit)

        # 翻訳ボタン行 (モデル選択付き)
        translate_row = QHBoxLayout()
        translate_row.addWidget(QLabel("モデル:"))
        self.model_combo = QComboBox()
        translate_row.addWidget(self.model_combo, stretch=1)
        self.refresh_models_btn = QPushButton("一覧更新")
        self.refresh_models_btn.clicked.connect(self.load_ollama_models)
        translate_row.addWidget(self.refresh_models_btn)
        self.translate_btn = QPushButton("翻訳開始")
        self.translate_btn.clicked.connect(self.start_translate)
        self.translate_btn.setEnabled(False)
        translate_row.addWidget(self.translate_btn)
        layout.addLayout(translate_row)

        # 訳文表示 (編集可能: 読み上げ前に手直しできる)
        layout.addWidget(QLabel("訳文 (日本語 / 読み上げ前に編集可):"))
        self.translated_edit = QTextEdit()
        layout.addWidget(self.translated_edit)

        # 読み上げ設定行
        speak_row = QHBoxLayout()
        speak_row.addWidget(QLabel("話者:"))
        self.speaker_combo = QComboBox()
        speak_row.addWidget(self.speaker_combo, stretch=1)
        self.speak_btn = QPushButton("読み上げ開始")
        self.speak_btn.clicked.connect(self.start_speak)
        self.speak_btn.setEnabled(False)
        speak_row.addWidget(self.speak_btn)
        layout.addLayout(speak_row)

        # ステータス表示
        self.status_label = QLabel("準備完了")
        layout.addWidget(self.status_label)

        self.setLayout(layout)

    # ---------------- VOICEVOX話者一覧取得 ----------------
    def load_speakers(self):
        try:
            res = requests.get(f"{VOICEVOX_BASE_URL}/speakers", timeout=10)
            res.raise_for_status()
            speakers = res.json()
            for sp in speakers:
                name = sp.get("name", "unknown")
                for style in sp.get("styles", []):
                    label = f"{name} - {style.get('name')}"
                    self.speaker_map[label] = style.get("id")
                    self.speaker_combo.addItem(label)
            if self.speaker_combo.count() == 0:
                self.speaker_combo.addItem("デフォルト話者")
                self.speaker_map["デフォルト話者"] = DEFAULT_SPEAKER_ID
        except Exception:
            # VOICEVOXが未起動でもアプリ自体は起動できるようにする
            self.speaker_combo.addItem("デフォルト話者 (VOICEVOX未接続)")
            self.speaker_map["デフォルト話者 (VOICEVOX未接続)"] = DEFAULT_SPEAKER_ID
            self.status_label.setText(
                "VOICEVOXに接続できませんでした。読み上げ時に再接続を試みます。"
            )

    # ---------------- Ollamaモデル一覧取得 (ollama list相当) ----------------
    def load_ollama_models(self):
        self.model_combo.clear()
        try:
            res = requests.get(OLLAMA_TAGS_URL, timeout=10)
            res.raise_for_status()
            data = res.json()
            models = [m.get("name") for m in data.get("models", []) if m.get("name")]
            if not models:
                raise ValueError("インストール済みのモデルが見つかりませんでした。")
            self.model_combo.addItems(models)
            self.status_label.setText(f"モデル一覧を取得しました ({len(models)}件)")
        except requests.exceptions.ConnectionError:
            self.model_combo.addItem(OLLAMA_MODEL_FALLBACK)
            self.status_label.setText(
                "Ollamaに接続できません。`ollama serve` の起動を確認して「一覧更新」を押してください。"
            )
        except Exception as e:
            self.model_combo.addItem(OLLAMA_MODEL_FALLBACK)
            self.status_label.setText(f"モデル一覧取得に失敗しました: {e}")

    # ---------------- 音声入力デバイス一覧取得 ----------------
    def load_audio_devices(self):
        self.audio_device_combo.clear()
        self.audio_device_map.clear()

        if not AUDIO_LIBS_AVAILABLE:
            self.audio_device_combo.addItem("(sounddevice未インストール)")
            self.record_btn.setEnabled(False)
            self.status_label.setText(
                "音声入力を使うには `pip install sounddevice numpy soundfile faster-whisper` が必要です。"
            )
            return

        try:
            devices = sd.query_devices()
            preferred_index = None
            for idx, dev in enumerate(devices):
                if dev.get("max_input_channels", 0) > 0:
                    label = f"[{idx}] {dev.get('name')}"
                    self.audio_device_map[label] = idx
                    self.audio_device_combo.addItem(label)
                    if "blackhole" in dev.get("name", "").lower() and preferred_index is None:
                        preferred_index = self.audio_device_combo.count() - 1

            if self.audio_device_combo.count() == 0:
                self.audio_device_combo.addItem("入力デバイスが見つかりません")
                self.record_btn.setEnabled(False)
            else:
                self.record_btn.setEnabled(True)
                if preferred_index is not None:
                    self.audio_device_combo.setCurrentIndex(preferred_index)
        except Exception as e:
            self.audio_device_combo.addItem("デバイス取得エラー")
            self.record_btn.setEnabled(False)
            self.status_label.setText(f"音声デバイス一覧の取得に失敗しました: {e}")

    # ---------------- 録音/文字起こし処理 ----------------
    def toggle_recording(self):
        if not self.is_recording:
            self.start_recording()
        else:
            self.stop_recording()

    def start_recording(self):
        label = self.audio_device_combo.currentText()
        device_index = self.audio_device_map.get(label)
        if device_index is None:
            QMessageBox.warning(self, "警告", "有効な音声入力デバイスを選択してください。")
            return

        try:
            dev_info = sd.query_devices(device_index)
            samplerate = int(dev_info.get("default_samplerate") or 44100)
            channels = min(2, dev_info.get("max_input_channels", 1)) or 1
        except Exception:
            samplerate, channels = 44100, 2

        self.is_recording = True
        self.record_btn.setText("録音停止")
        self.audio_device_combo.setEnabled(False)
        self.refresh_devices_btn.setEnabled(False)
        self.status_label.setText("録音中... (もう一度ボタンを押すと停止し、文字起こしを開始します)")

        self.record_worker = RecordTranscribeWorker(
            device_index=device_index, channels=channels, samplerate=samplerate
        )
        self.record_worker.partial_result.connect(self.on_record_partial)
        self.record_worker.finished.connect(self.on_record_done)
        self.record_worker.error.connect(self.on_record_error)
        self.record_worker.start()

    def stop_recording(self):
        if self.record_worker:
            self.record_btn.setEnabled(False)
            self.status_label.setText("最後のチャンクを文字起こし中...")
            self.record_worker.stop_recording()

    def on_record_partial(self, text_so_far: str):
        self.original_edit.setPlainText(text_so_far)
        # カーソルを末尾に移動して、追記されていく様子が見えるようにする
        cursor = self.original_edit.textCursor()
        cursor.movePosition(cursor.MoveOperation.End)
        self.original_edit.setTextCursor(cursor)
        self.status_label.setText("録音中... (逐次認識結果を表示しています)")

    def on_record_done(self, text: str):
        self.original_edit.setPlainText(text)
        self.translate_btn.setEnabled(bool(text.strip()))
        self._reset_recording_ui()
        self.status_label.setText("文字起こし完了")

    def on_record_error(self, msg: str):
        QMessageBox.critical(self, "音声入力エラー", msg)
        self._reset_recording_ui()
        self.status_label.setText("音声入力失敗")

    def _reset_recording_ui(self):
        self.is_recording = False
        self.record_btn.setText("録音開始")
        self.record_btn.setEnabled(True)
        self.audio_device_combo.setEnabled(True)
        self.refresh_devices_btn.setEnabled(True)

    # ---------------- ファイル選択/読み込み ----------------
    def select_file(self):
        path, _ = QFileDialog.getOpenFileName(
            self, "英語テキストファイルを選択", "", "Text Files (*.txt);;All Files (*)"
        )
        if path:
            self.selected_path = path
            self.file_label.setText(os.path.basename(path))
            self.load_btn.setEnabled(True)

    def load_file(self):
        if not self.selected_path:
            QMessageBox.warning(self, "警告", "先にファイルを選択してください")
            return
        try:
            with open(self.selected_path, "r", encoding="utf-8") as f:
                self.original_text = f.read()
            self.original_edit.setPlainText(self.original_text)
            self.translate_btn.setEnabled(bool(self.original_text.strip()))
            self.status_label.setText("ファイル読み込み完了")
        except UnicodeDecodeError:
            QMessageBox.critical(
                self, "エラー",
                "UTF-8として読み込めませんでした。ファイルの文字コードを確認してください。"
            )
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"ファイル読み込みに失敗しました: {e}")

    # ---------------- 翻訳処理 ----------------
    def start_translate(self):
        text = self.original_edit.toPlainText().strip()
        if not text:
            return
        model = self.model_combo.currentText().strip()
        if not model:
            QMessageBox.warning(self, "警告", "モデルが選択されていません。「一覧更新」を押してください。")
            return
        self.translate_btn.setEnabled(False)
        self.status_label.setText(
            f"翻訳中... ({model} / モデルサイズによっては数分かかります)"
        )
        self.translate_worker = TranslateWorker(text, model=model)
        self.translate_worker.finished.connect(self.on_translate_done)
        self.translate_worker.error.connect(self.on_translate_error)
        self.translate_worker.start()

    def on_translate_done(self, translated: str):
        self.translated_edit.setPlainText(translated)
        self.translate_btn.setEnabled(True)
        self.speak_btn.setEnabled(True)
        self.status_label.setText("翻訳完了")

    def on_translate_error(self, msg: str):
        QMessageBox.critical(self, "翻訳エラー", msg)
        self.translate_btn.setEnabled(True)
        self.status_label.setText("翻訳失敗")

    # ---------------- 読み上げ処理 ----------------
    def start_speak(self):
        text = self.translated_edit.toPlainText().strip()
        if not text:
            return
        speaker_label = self.speaker_combo.currentText()
        speaker_id = self.speaker_map.get(speaker_label, DEFAULT_SPEAKER_ID)

        self.speak_btn.setEnabled(False)
        self.status_label.setText("音声合成中...")
        self.tts_worker = TTSWorker(text, speaker=speaker_id)
        self.tts_worker.finished.connect(self.on_speak_done)
        self.tts_worker.error.connect(self.on_speak_error)
        self.tts_worker.start()

    def on_speak_done(self):
        self.speak_btn.setEnabled(True)
        self.status_label.setText("読み上げ完了")

    def on_speak_error(self, msg: str):
        QMessageBox.critical(self, "読み上げエラー", msg)
        self.speak_btn.setEnabled(True)
        self.status_label.setText("読み上げ失敗")


def main():
    app = QApplication(sys.argv)
    win = TranslatorApp()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

まとめ

「ローカルLLMで英日翻訳+読み上げ」という単純な要件から出発し、対話を重ねる中でOllamaモデルの動的選択、VOICEVOXのキャラクター選択、BlackHole経由の音声入力、逐次文字起こし表示と、段階的に機能を積み上げていきました。すべてローカル完結(Ollama+faster-whisper+VOICEVOX)で動作するため、外部APIへの依存やコストを気にせず、システム音声のリアルタイム翻訳ツールとして拡張していける土台ができたと思います。

コメントを残す

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