transcribe_qwen3_asr.py ダウンロード/コピー

transcribe_qwen3_asr.py をダウンロード

transcribe_qwen3_asr.py
transcribe_qwen3_asr.py
   1"""
   2Qwen3-ASRコマンドライン音声文字起こしツール。
   3
   4このスクリプトは、Qwen3-ASRモデルを使用して音声ファイルを文字起こしするための
   5コマンドラインインターフェースを提供します。
   6入力ファイルごとに以下の3つの出力ファイルを生成します。
   7
   8  1. ``<name>-time.txt`` : タイムスタンプ付きのテキスト(ForcedAlignerが必要な場合)
   9  2. ``<name>.txt``      : プレーンな文字起こしテキスト
  10  3. ``<name>-info.txt`` : 実行時間、モデル、オーディオ、および結果のメタデータ
  11
  12ASRエンジンは ``ASRBackend`` クラスの背後に隔離されており、
  13CLI、出力ライター、環境レポートを変更することなく、
  14faster-whisper、openai-whisper、または他のエンジンを追加できるよう設計されています。
  15
  16.. seealso::
  17   :doc:`transcribe_qwen3_asr_usage` (利用ガイドへの架空のリンク)
  18"""
  19
  20from __future__ import annotations
  21
  22import argparse
  23import glob
  24import importlib.metadata
  25import json
  26import os
  27import platform
  28import subprocess
  29import sys
  30import tempfile
  31import time
  32import traceback
  33from abc import ABC, abstractmethod
  34from dataclasses import asdict, dataclass, field
  35from datetime import datetime, timezone
  36from pathlib import Path
  37from typing import Any, Callable, Optional
  38
  39
  40MODEL_ALIASES = {
  41    "0.6B": "Qwen/Qwen3-ASR-0.6B",
  42    "1.7B": "Qwen/Qwen3-ASR-1.7B",
  43    "Qwen/Qwen3-ASR-0.6B": "Qwen/Qwen3-ASR-0.6B",
  44    "Qwen/Qwen3-ASR-1.7B": "Qwen/Qwen3-ASR-1.7B",
  45}
  46
  47LANGUAGE_ALIASES = {
  48    "ja": "Japanese",
  49    "japanese": "Japanese",
  50    "en": "English",
  51    "english": "English",
  52    "zh": "Chinese",
  53    "chinese": "Chinese",
  54    "ko": "Korean",
  55    "korean": "Korean",
  56}
  57
  58
  59@dataclass
  60class Segment:
  61    """文字起こしされたテキストのセグメントを表すデータクラス。
  62
  63    :param start: セグメントの開始時間(秒)。
  64    :type start: Optional[float]
  65    :param end: セグメントの終了時間(秒)。
  66    :type end: Optional[float]
  67    :param text: セグメント内のテキスト。
  68    :type text: str
  69    """
  70    start: Optional[float]
  71    end: Optional[float]
  72    text: str
  73
  74
  75@dataclass
  76class ASRResult:
  77    """ASRエンジンの文字起こし結果を表すデータクラス。
  78
  79    :param text: 文字起こしされた全テキスト。
  80    :type text: str
  81    :param language: 検出された言語コードまたは名称。指定がない場合はNone。
  82    :type language: Optional[str]
  83    :param segments: タイムスタンプ付きのテキストセグメントのリスト。デフォルトは空リスト。
  84    :type segments: list[Segment]
  85    :param engine_metadata: エンジン固有の追加メタデータ。デフォルトは空の辞書。
  86    :type engine_metadata: dict[str, Any]
  87    """
  88    text: str
  89    language: Optional[str] = None
  90    segments: list[Segment] = field(default_factory=list)
  91    engine_metadata: dict[str, Any] = field(default_factory=dict)
  92
  93
  94@dataclass
  95class OutputPaths:
  96    """出力ファイルのパスを保持するデータクラス。
  97
  98    :param timestamped: タイムスタンプ付きテキストの出力パス。
  99    :type timestamped: Path
 100    :param plain: プレーンテキストの出力パス。
 101    :type plain: Path
 102    :param info: 情報JSONファイルの出力パス。
 103    :type info: Path
 104    """
 105    timestamped: Path
 106    plain: Path
 107    info: Path
 108
 109
 110class ASRBackend(ABC):
 111    """ローカルASRエンジン用の共通インターフェース。
 112
 113    この抽象基底クラスは、さまざまなASRバックエンド(例: Qwen3-ASR、Whisperなど)が
 114    共通のインターフェースを通じて使用できるようにするための基本的なメソッドを定義します。
 115    新しいASRエンジンを追加する際は、このクラスを継承し、抽象メソッドを実装する必要があります。
 116    """
 117
 118    backend_name = "unknown"
 119
 120    @abstractmethod
 121    def load(self) -> None:
 122        """1つ以上のファイルを処理する前に、一度モデルをロードします。
 123
 124        このメソッドは、モデルの初期化やリソースの準備を行います。
 125        """
 126
 127    @abstractmethod
 128    def transcribe(self, audio_path: str, language: Optional[str]) -> ASRResult:
 129        """1つの音声ファイルを文字起こしし、エンジンに依存しない結果を返します。
 130
 131        :param audio_path: 文字起こしする音声ファイルへのパス。
 132        :type audio_path: str
 133        :param language: 音声の言語。指定がない場合は自動検出されます。
 134        :type language: Optional[str]
 135        :returns: 文字起こし結果を含む :class:`ASRResult` オブジェクト。
 136        :rtype: ASRResult
 137        """
 138
 139    @abstractmethod
 140    def describe(self) -> dict[str, Any]:
 141        """このバックエンドで使用されている設定を辞書形式で返します。
 142
 143        :returns: バックエンドの設定を含む辞書。
 144        :rtype: dict[str, Any]
 145        """
 146
 147
 148class Qwen3ASRBackend(ASRBackend):
 149    """Qwen3-ASRモデルを使用するASRバックエンドの実装。
 150
 151    このクラスは、Qwen3-ASRライブラリをラップし、
 152    ``ASRBackend`` インターフェースに準拠した文字起こし機能を提供します。
 153
 154    :param model_name: 使用するQwen3-ASRモデルの名前またはエイリアス。
 155    :type model_name: str
 156    :param device: モデルを実行するデバイス(例: "cpu", "cuda", "cuda:0")。
 157    :type device: str
 158    :param dtype_name: モデルのデータ型(例: "float32", "float16", "bfloat16")。
 159    :type dtype_name: str
 160    :param timestamps: タイムスタンプ付きセグメントを生成するかどうか(True/False)。
 161    :type timestamps: bool
 162    :param aligner_name: タイムスタンプ生成に使用するアライナーモデルの名前。
 163    :type aligner_name: str
 164    :param max_inference_batch_size: 推論時の最大バッチサイズ。
 165    :type max_inference_batch_size: int
 166    :param max_new_tokens: 生成される新しいトークンの最大数。
 167    :type max_new_tokens: int
 168    :param attention: Attentionの実装タイプ(例: "auto", "sdpa", "eager", "flash_attention_2")。
 169    :type attention: str
 170    """
 171    backend_name = "qwen3-asr"
 172
 173    def __init__(
 174        self,
 175        model_name: str,
 176        device: str,
 177        dtype_name: str,
 178        timestamps: bool,
 179        aligner_name: str,
 180        max_inference_batch_size: int,
 181        max_new_tokens: int,
 182        attention: str,
 183    ) -> None:
 184        """Qwen3ASRBackendを初期化します。
 185
 186        :param model_name: 使用するQwen3-ASRモデルの名前またはエイリアス。
 187        :type model_name: str
 188        :param device: モデルを実行するデバイス(例: "cpu", "cuda", "cuda:0")。
 189        :type device: str
 190        :param dtype_name: モデルのデータ型(例: "float32", "float16", "bfloat16")。
 191        :type dtype_name: str
 192        :param timestamps: タイムスタンプ付きセグメントを生成するかどうか(True/False)。
 193        :type timestamps: bool
 194        :param aligner_name: タイムスタンプ生成に使用するアライナーモデルの名前。
 195        :type aligner_name: str
 196        :param max_inference_batch_size: 推論時の最大バッチサイズ。
 197        :type max_inference_batch_size: int
 198    :param max_new_tokens: 生成される新しいトークンの最大数。
 199        :type max_new_tokens: int
 200        :param attention: Attentionの実装タイプ(例: "auto", "sdpa", "eager", "flash_attention_2")。
 201        :type attention: str
 202        """
 203        self.model_name = MODEL_ALIASES.get(model_name, model_name)
 204        self.requested_device = device
 205        self.requested_dtype = dtype_name
 206        self.timestamps = timestamps
 207        self.aligner_name = aligner_name
 208        self.max_inference_batch_size = max_inference_batch_size
 209        self.max_new_tokens = max_new_tokens
 210        self.attention = attention
 211        self.model = None
 212        self.torch = None
 213        self.device_map = ""
 214        self.dtype = None
 215        self.dtype_label = ""
 216
 217    def _resolve_device_and_dtype(self) -> None:
 218        """要求されたデバイスとデータ型をPyTorchの実際の値に解決します。
 219
 220        このメソッドは、指定された ``device`` と ``dtype_name`` の文字列値を
 221        PyTorchが認識するデバイス文字列とデータ型オブジェクトに変換します。
 222        GPUの利用可能性やbfloat16のサポート状況に応じて最適な値を自動選択します。
 223
 224        :raises RuntimeError: CUDAが要求されたにもかかわらず利用できない場合。
 225        :raises ValueError: サポートされていないdtypeが指定された場合、またはCPU上でfloat16が指定された場合。
 226        """
 227        import torch
 228
 229        self.torch = torch
 230        device = self.requested_device.strip().lower()
 231        if not device or device == "auto":
 232            device = "cuda:0" if torch.cuda.is_available() else "cpu"
 233        elif device == "cuda":
 234            device = "cuda:0"
 235
 236        if device.startswith("cuda") and not torch.cuda.is_available():
 237            raise RuntimeError("CUDAが要求されましたが、torch.cuda.is_available()がFalseです")
 238
 239        dtype_name = self.requested_dtype.strip().lower()
 240        if dtype_name == "auto":
 241            if device.startswith("cuda"):
 242                dtype_name = "bfloat16" if torch.cuda.is_bf16_supported() else "float16"
 243            else:
 244                dtype_name = "float32"
 245
 246        dtype_table = {
 247            "bfloat16": torch.bfloat16,
 248            "bf16": torch.bfloat16,
 249            "float16": torch.float16,
 250            "fp16": torch.float16,
 251            "float32": torch.float32,
 252            "fp32": torch.float32,
 253        }
 254        if dtype_name not in dtype_table:
 255            raise ValueError(f"サポートされていないdtype: {self.requested_dtype}")
 256        if device == "cpu" and dtype_name in {"float16", "fp16"}:
 257            raise ValueError("CPU上でのfloat16は推奨されません。--dtype float32を使用してください")
 258
 259        self.device_map = device
 260        self.dtype = dtype_table[dtype_name]
 261        self.dtype_label = str(self.dtype).replace("torch.", "")
 262
 263    def load(self) -> None:
 264        """Qwen3-ASRモデルと必要に応じてForcedAlignerをロードします。
 265
 266        デバイスとデータ型を解決した後、指定された設定に基づいて
 267        Qwen3ASRModelをHugging Face Hubからロードします。
 268        タイムスタンプが有効な場合は、ForcedAlignerも初期化されます。
 269        """
 270        self._resolve_device_and_dtype()
 271        from qwen_asr import Qwen3ASRModel
 272
 273        kwargs: dict[str, Any] = {
 274            "dtype": self.dtype,
 275            "device_map": self.device_map,
 276            "max_inference_batch_size": self.max_inference_batch_size,
 277            "max_new_tokens": self.max_new_tokens,
 278        }
 279        if self.attention != "auto":
 280            kwargs["attn_implementation"] = self.attention
 281
 282        if self.timestamps:
 283            kwargs["forced_aligner"] = self.aligner_name
 284            aligner_kwargs: dict[str, Any] = {
 285                "dtype": self.dtype,
 286                "device_map": self.device_map,
 287            }
 288            if self.attention != "auto":
 289                aligner_kwargs["attn_implementation"] = self.attention
 290            kwargs["forced_aligner_kwargs"] = aligner_kwargs
 291
 292        print(f"Loading ASR model : {self.model_name}", flush=True)
 293        if self.timestamps:
 294            print(f"Loading aligner   : {self.aligner_name}", flush=True)
 295        print(f"Device / dtype   : {self.device_map} / {self.dtype_label}", flush=True)
 296        self.model = Qwen3ASRModel.from_pretrained(self.model_name, **kwargs)
 297
 298    @staticmethod
 299    def _timestamp_item_to_segment(item: Any) -> Segment:
 300        """タイムスタンプ付きのアイテムを :class:`Segment` オブジェクトに変換します。
 301
 302        Qwen3-ASRモデルが返すタイムスタンプ形式から、
 303        一貫した :class:`Segment` データクラスに変換します。
 304
 305        :param item: タイムスタンプ情報を含む辞書またはオブジェクト。
 306        :type item: Any
 307        :returns: 変換された :class:`Segment` オブジェクト。
 308        :rtype: Segment
 309        """
 310        if isinstance(item, dict):
 311            text = str(item.get("text", ""))
 312            start = item.get("start_time", item.get("start"))
 313            end = item.get("end_time", item.get("end"))
 314        else:
 315            text = str(getattr(item, "text", ""))
 316            start = getattr(item, "start_time", getattr(item, "start", None))
 317            end = getattr(item, "end_time", getattr(item, "end", None))
 318        return Segment(
 319            start=float(start) if start is not None else None,
 320            end=float(end) if end is not None else None,
 321            text=text,
 322        )
 323
 324    @classmethod
 325    def _convert_timestamps(cls, raw: Any) -> list[Segment]:
 326        """Qwen3-ASRの生のタイムスタンプ出力をSegmentのリストに変換します。
 327
 328        モデルの出力が複数のリストレベルを持つ場合があるため、それを平坦化し、
 329        各タイムスタンプアイテムを :class:`Segment` オブジェクトに変換します。
 330
 331        :param raw: Qwen3-ASRから返された生のタイムスタンプデータ。
 332        :type raw: Any
 333        :returns: :class:`Segment` オブジェクトのリスト。
 334        :rtype: list[Segment]
 335        """
 336        if raw is None:
 337            return []
 338        # 一部のバージョンでは、単一の入力に対して余分なリストレベルを返す場合があります。
 339        while isinstance(raw, (list, tuple)) and len(raw) == 1 and isinstance(raw[0], (list, tuple)):
 340            raw = raw[0]
 341        if not isinstance(raw, (list, tuple)):
 342            raw = [raw]
 343        return [cls._timestamp_item_to_segment(item) for item in raw]
 344
 345    def transcribe(self, audio_path: str, language: Optional[str]) -> ASRResult:
 346        """Qwen3-ASRモデルを使用して音声ファイルを文字起こしします。
 347
 348        :param audio_path: 文字起こしする音声ファイルへのパス。
 349        :type audio_path: str
 350        :param language: 音声の言語。指定がない場合は自動検出されます。
 351        :type language: Optional[str]
 352        :returns: 文字起こし結果を含む :class:`ASRResult` オブジェクト。
 353        :rtype: ASRResult
 354        :raises RuntimeError: モデルがロードされていない場合、またはQwen3-ASRが結果を返さなかった場合。
 355        """
 356        if self.model is None:
 357            raise RuntimeError("モデルがロードされていません")
 358        results = self.model.transcribe(
 359            audio=audio_path,
 360            language=language,
 361            return_time_stamps=self.timestamps,
 362        )
 363        if not results:
 364            raise RuntimeError("Qwen3-ASRが結果を返しませんでした")
 365        raw = results[0]
 366        text = str(getattr(raw, "text", ""))
 367        detected_language = getattr(raw, "language", None)
 368        raw_timestamps = getattr(raw, "time_stamps", None)
 369        segments = self._convert_timestamps(raw_timestamps)
 370        return ASRResult(
 371            text=text,
 372            language=str(detected_language) if detected_language is not None else None,
 373            segments=segments,
 374            engine_metadata={"timestamp_items": len(segments)},
 375        )
 376
 377    def describe(self) -> dict[str, Any]:
 378        """このバックエンドで使用されている設定を辞書形式で返します。
 379
 380        :returns: バックエンドの設定を含む辞書。
 381        :rtype: dict[str, Any]
 382        """
 383        return {
 384            "backend": self.backend_name,
 385            "model": self.model_name,
 386            "device": self.device_map,
 387            "dtype": self.dtype_label,
 388            "timestamps": self.timestamps,
 389            "aligner": self.aligner_name if self.timestamps else None,
 390            "attention": self.attention,
 391            "max_inference_batch_size": self.max_inference_batch_size,
 392            "max_new_tokens": self.max_new_tokens,
 393        }
 394
 395
 396def normalize_language(value: str) -> Optional[str]:
 397    """指定された言語の文字列を標準形式に正規化します。
 398
 399    "auto"や空文字列はNoneに変換され、一般的なエイリアスは正式な名称に変換されます。
 400
 401    :param value: 正規化する言語文字列。
 402    :type value: str
 403    :returns: 正規化された言語文字列、または自動検出を意味するNone。
 404    :rtype: Optional[str]
 405    """
 406    value = value.strip()
 407    if not value or value.lower() == "auto":
 408        return None
 409    return LANGUAGE_ALIASES.get(value.lower(), value)
 410
 411
 412def package_version(name: str) -> Optional[str]:
 413    """指定されたパッケージのバージョンを取得します。
 414
 415    :param name: バージョンを取得するパッケージの名前。
 416    :type name: str
 417    :returns: パッケージのバージョン文字列、またはパッケージが見つからない場合はNone。
 418    :rtype: Optional[str]
 419    """
 420    try:
 421        return importlib.metadata.version(name)
 422    except importlib.metadata.PackageNotFoundError:
 423        return None
 424
 425
 426def command_output(command: list[str]) -> Optional[str]:
 427    """指定されたコマンドを実行し、その標準出力を取得します。
 428
 429    コマンドの実行に失敗した場合や標準出力が空の場合はNoneを返します。
 430
 431    :param command: 実行するコマンドとその引数のリスト。
 432    :type command: list[str]
 433    :returns: コマンドの標準出力文字列、またはエラー/空出力の場合はNone。
 434    :rtype: Optional[str]
 435    """
 436    try:
 437        completed = subprocess.run(
 438            command,
 439            stdout=subprocess.PIPE,
 440            stderr=subprocess.PIPE,
 441            text=True,
 442            check=False,
 443        )
 444        value = completed.stdout.strip()
 445        return value or None
 446    except (OSError, ValueError):
 447        return None
 448
 449
 450def get_audio_duration(path: str) -> Optional[float]:
 451    """指定された音声ファイルの長さを秒単位で取得します。
 452
 453    ffprobeコマンドを使用して音声ファイルのduration情報を解析します。
 454
 455    :param path: 音声ファイルへのパス。
 456    :type path: str
 457    :returns: 音声ファイルの長さ(秒)、または取得できない場合はNone。
 458    :rtype: Optional[float]
 459    """
 460    value = command_output([
 461        "ffprobe", "-v", "error", "-show_entries", "format=duration",
 462        "-of", "default=noprint_wrappers=1:nokey=1", path,
 463    ])
 464    try:
 465        return float(value) if value is not None else None
 466    except ValueError:
 467        return None
 468
 469
 470def format_hms(seconds: Optional[float]) -> str:
 471    """秒数をHH:MM:SS.ss形式の文字列にフォーマットします。
 472
 473    :param seconds: フォーマットする秒数。Noneの場合は"??:??:??.??"を返します。
 474    :type seconds: Optional[float]
 475    :returns: フォーマットされた時間文字列。
 476    :rtype: str
 477    """
 478    if seconds === None:
 479        return "??:??:??.??"
 480    seconds = max(0.0, float(seconds))
 481    hours = int(seconds // 3600)
 482    minutes = int((seconds % 3600) // 60)
 483    secs = seconds % 60
 484    return f"{hours:02d}:{minutes:02d}:{secs:05.2f}"
 485
 486
 487def collect_environment(torch_module: Any) -> dict[str, Any]:
 488    """現在の実行環境に関する情報を収集します。
 489
 490    OS、Python、インストールされている主要なパッケージのバージョン、
 491    FFmpegのバージョン、CUDAの利用可能性とGPUの詳細などを取得します。
 492
 493    :param torch_module: PyTorchモジュールへの参照。
 494    :type torch_module: Any
 495    :returns: 環境情報を格納した辞書。
 496    :rtype: dict[str, Any]
 497    """
 498    info: dict[str, Any] = {
 499        "timestamp_utc": datetime.now(timezone.utc).isoformat(),
 500        "platform": platform.platform(),
 501        "os": platform.system(),
 502        "os_release": platform.release(),
 503        "machine": platform.machine(),
 504        "processor": platform.processor(),
 505        "python": platform.python_version(),
 506        "python_executable": sys.executable,
 507        "packages": {
 508            name: package_version(name)
 509            for name in (
 510                "qwen-asr", "torch", "transformers", "accelerate",
 511                "huggingface-hub", "soundfile", "librosa",
 512            )
 513        },
 514        "ffmpeg": command_output(["ffmpeg", "-version"]),
 515    }
 516    if info["ffmpeg"]:
 517        info["ffmpeg"] = str(info["ffmpeg"]).splitlines()[0]
 518
 519    cuda_available = bool(torch_module.cuda.is_available())
 520    cuda: dict[str, Any] = {
 521        "available": cuda_available,
 522        "torch_cuda_version": torch_module.version.cuda,
 523        "cudnn_version": torch_module.backends.cudnn.version() if cuda_available else None,
 524        "device_count": torch_module.cuda.device_count() if cuda_available else 0,
 525        "devices": [],
 526    }
 527    if cuda_available:
 528        for index in range(torch_module.cuda.device_count()):
 529            prop = torch_module.cuda.get_device_properties(index)
 530            cuda["devices"].append({
 531                "index": index,
 532                "name": prop.name,
 533                "total_memory_gib": round(prop.total_memory / 1024**3, 3),
 534                "compute_capability": f"{prop.major}.{prop.minor}",
 535            })
 536    info["cuda"] = cuda
 537    return info
 538
 539
 540def print_environment(info: dict[str, Any]) -> None:
 541    """収集した環境情報をコンソールに表示します。
 542
 543    :param info: :func:`collect_environment` によって収集された環境情報の辞書。
 544    :type info: dict[str, Any]
 545    """
 546    print("\n=== ランタイム環境 ===")
 547    print(f"OS              : {info['platform']}")
 548    print(f"Python          : {info['python']}")
 549    print(f"実行可能パス      : {info['python_executable']}")
 550    print(f"qwen-asr        : {info['packages']['qwen-asr']}")
 551    print(f"PyTorch         : {info['packages']['torch']}")
 552    print(f"Transformers    : {info['packages']['transformers']}")
 553    print(f"CUDA利用可能     : {info['cuda']['available']}")
 554    print(f"Torch CUDA      : {info['cuda']['torch_cuda_version']}")
 555    for gpu in info["cuda"]["devices"]:
 556        print(
 557            f"GPU {gpu['index']}           : {gpu['name']} "
 558            f"({gpu['total_memory_gib']:.2f} GiB, CC {gpu['compute_capability']})"
 559        )
 560
 561
 562def make_output_paths(input_path: str, args: argparse.Namespace) -> OutputPaths:
 563    """入力ファイルに基づいて出力ファイルのパスを生成します。
 564
 565    CLI引数で特定の出力ファイル名が指定されている場合はそれを使用し、
 566    そうでない場合は入力ファイルのステム名に基づいてデフォルトのパスを作成します。
 567
 568    :param input_path: 入力音声ファイルへのパス。
 569    :type input_path: str
 570    :param args: コマンドライン引数をパースしたオブジェクト。
 571    :type args: argparse.Namespace
 572    :returns: 生成された出力パスを含む :class:`OutputPaths` オブジェクト。
 573    :rtype: OutputPaths
 574    """
 575    stem = Path(input_path).stem
 576    return OutputPaths(
 577        timestamped=Path(args.outfile1) if args.outfile1 else Path(f"{stem}-time.txt"),
 578        plain=Path(args.outfile2) if args.outfile2 else Path(f"{stem}.txt"),
 579        info=Path(args.outfile3) if args.outfile3 else Path(f"{stem}-info.txt"),
 580    )
 581
 582
 583def write_transcript_outputs(
 584    paths: OutputPaths,
 585    result: ASRResult,
 586) -> None:
 587    """文字起こしされたテキストファイルを書き込みます。
 588
 589    この関数は、タイムスタンプ付きのテキストファイルとプレーンテキストファイルの両方を生成します。
 590    長時間の処理が中断された場合でも、中間結果がディスクに残るように、
 591    チャンク処理の各完了後にも呼び出されます。
 592
 593    :param paths: 出力ファイルのパスを含む :class:`OutputPaths` オブジェクト。
 594    :type paths: OutputPaths
 595    :param result: 文字起こし結果を含む :class:`ASRResult` オブジェクト。
 596    :type result: ASRResult
 597    """
 598    with paths.timestamped.open("w", encoding="utf-8") as handle:
 599        if result.segments:
 600            for seg in result.segments:
 601                if seg.start is None and seg.end is None:
 602                    handle.write(f"{seg.text}\n")
 603                else:
 604                    handle.write(
 605                        f"[{format_hms(seg.start)} - {format_hms(seg.end)}] {seg.text}\n"
 606                    )
 607        else:
 608            handle.write("[タイムスタンプ利用不可]\n")
 609            handle.write(result.text)
 610            if result.text and not result.text.endswith("\n"):
 611                handle.write("\n")
 612
 613    with paths.plain.open("w", encoding="utf-8") as handle:
 614        handle.write(result.text)
 615        if result.text and not result.text.endswith("\n"):
 616            handle.write("\n")
 617
 618
 619def write_outputs(
 620    paths: OutputPaths,
 621    result: ASRResult,
 622    report: dict[str, Any],
 623) -> None:
 624    """すべての出力ファイル(文字起こしテキストと情報JSON)を書き込みます。
 625
 626    :param paths: 出力ファイルのパスを含む :class:`OutputPaths` オブジェクト。
 627    :type paths: OutputPaths
 628    :param result: 文字起こし結果を含む :class:`ASRResult` オブジェクト。
 629    :type result: ASRResult
 630    :param report: 実行レポートデータを含む辞書。
 631    :type report: dict[str, Any]
 632    """
 633    write_transcript_outputs(paths, result)
 634
 635    with paths.info.open("w", encoding="utf-8") as handle:
 636        json.dump(report, handle, ensure_ascii=False, indent=2, default=str)
 637        handle.write("\n")
 638
 639
 640def transcribe_with_optional_chunks(
 641    backend: ASRBackend,
 642    audio_path: str,
 643    language: Optional[str],
 644    duration: Optional[float],
 645    chunk_seconds: float,
 646    chunk_overlap: float,
 647    use_chunks: bool,
 648    progress_callback: Optional[Callable[[ASRResult, dict[str, Any]], None]] = None,
 649) -> ASRResult:
 650    """アライナーの入力長に制限がある場合に、長い音声を分割して文字起こしします。
 651
 652    この関数は、指定された ``chunk_seconds`` に基づいて音声を小さなチャンクに分割し、
 653    各チャンクをASRバックエンドで文字起こしします。
 654    チャンク処理はバックエンドの外で行われるため、さまざまなASRエンジンで再利用可能です。
 655    タイムスタンプは元のファイルのタイムラインにシフトバックされます。
 656    チャンクの境界には、後の処理(例: LLMによるテキスト結合)で役立つマーカーが挿入されます。
 657
 658    :param backend: 使用するASRバックエンドの実装。
 659    :type backend: ASRBackend
 660    :param audio_path: 文字起こしする音声ファイルへのパス。
 661    :type audio_path: str
 662    :param language: 音声の言語。指定がない場合は自動検出されます。
 663    :type language: Optional[str]
 664    :param duration: 音声ファイルの全体の長さ(秒)。
 665    :type duration: Optional[float]
 666    :param chunk_seconds: 音声を分割するチャンクの秒数。0以下の場合、分割は行われません。
 667    :type chunk_seconds: float
 668    :param chunk_overlap: 各チャンクの前後で重複させる秒数。重複により境界での情報損失を防ぎます。
 669    :type chunk_overlap: float
 670    :param use_chunks: チャンク分割を使用するかどうか。Falseの場合、全音声を一度に処理します。
 671    :type use_chunks: bool
 672    :param progress_callback: チャンクが完了するたびに呼び出されるコールバック関数。
 673                              部分的なASRResultと進捗情報を引数に取ります。
 674    :type progress_callback: Optional[Callable[[ASRResult, dict[str, Any]], None]]
 675    :returns: 全てのチャンクを結合した最終的な文字起こし結果。
 676    :rtype: ASRResult
 677    :raises RuntimeError: FFmpegがインストールされていない場合、またはFFmpegによるチャンク作成に失敗した場合。
 678    :raises ValueError: ``--chunk-overlap`` が無効な値の場合。
 679    """
 680    if not use_chunks or chunk_seconds <= 0 or duration is None or duration <= chunk_seconds:
 681        return backend.transcribe(audio_path, language)
 682
 683    if command_output(["ffmpeg", "-version"]) is None:
 684        raise RuntimeError("タイムスタンプアライメントのために長い音声を分割するにはffmpegが必要です")
 685
 686    if chunk_overlap < 0:
 687        raise ValueError("--chunk-overlapは0以上である必要があります")
 688    if chunk_overlap * 2 >= chunk_seconds:
 689        raise ValueError("--chunk-overlapは--chunk-secondsの半分未満である必要があります")
 690
 691    parts: list[str] = []
 692    segments: list[Segment] = []
 693    detected_languages: list[str] = []
 694    core_start = 0.0
 695    chunk_index = 0
 696
 697    with tempfile.TemporaryDirectory(prefix="qwen3_asr_") as temp_dir:
 698        while core_start < duration:
 699            chunk_index += 1
 700            core_end = min(duration, core_start + chunk_seconds)
 701            actual_start = max(0.0, core_start - (chunk_overlap if chunk_index > 1 else 0.0))
 702            actual_end = min(
 703                duration,
 704                core_end + (chunk_overlap if core_end < duration else 0.0),
 705            )
 706            length = actual_end - actual_start
 707            chunk_path = os.path.join(temp_dir, f"chunk_{chunk_index:04d}.wav")
 708            command = [
 709                "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
 710                "-ss", str(actual_start), "-t", str(length), "-i", audio_path,
 711                "-vn", "-ac", "1", "-ar", "16000", chunk_path,
 712            ]
 713            completed = subprocess.run(command, check=False)
 714            if completed.returncode != 0:
 715                raise RuntimeError(f"チャンク {chunk_index} の作成中にffmpegが失敗しました")
 716
 717            print(
 718                f"チャンク {chunk_index}: 実際の時間 {format_hms(actual_start)} - "
 719                f"{format_hms(actual_end)}、コア時間 {format_hms(core_start)} - "
 720                f"{format_hms(core_end)}",
 721                flush=True,
 722            )
 723            chunk_started = time.perf_counter()
 724            chunk_result = backend.transcribe(chunk_path, language)
 725            chunk_elapsed = time.perf_counter() - chunk_started
 726            marker = (
 727                f"[[ASR_CHUNK {chunk_index:04d} "
 728                f"ACTUAL={format_hms(actual_start)}..{format_hms(actual_end)} "
 729                f"CORE={format_hms(core_start)}..{format_hms(core_end)} "
 730                f"OVERLAP={chunk_overlap:.1f}s]]"
 731            )
 732            if chunk_index > 1:
 733                boundary = (
 734                    f"[[ASR_BOUNDARY {chunk_index - 1:04d}|{chunk_index:04d} "
 735                    f"OVERLAP={chunk_overlap:.1f}s; "
 736                    "LLM: この境界をまたがる重複または不完全なテキストを調整してください]]"
 737                )
 738                parts.append(boundary)
 739                segments.append(Segment(None, None, boundary))
 740            parts.append(marker)
 741            segments.append(Segment(None, None, marker))
 742            if chunk_result.text.strip():
 743                parts.append(chunk_result.text.strip())
 744            parts.append(f"[[/ASR_CHUNK {chunk_index:04d}]]")
 745            if chunk_result.language:
 746                detected_languages.append(chunk_result.language)
 747            for seg in chunk_result.segments:
 748                segments.append(Segment(
 749                    start=(seg.start + actual_start) if seg.start is not None else None,
 750                    end=(seg.end + actual_start) if seg.end is not None else None,
 751                    text=seg.text,
 752                ))
 753            segments.append(Segment(None, None, f"[[/ASR_CHUNK {chunk_index:04d}]]"))
 754            core_start = core_end
 755
 756            progress_percent = 100.0 * core_end / duration
 757            print(
 758                f"\n=== チャンク {chunk_index} 完了: "
 759                f"{progress_percent:.1f}% ({format_hms(core_end)} / {format_hms(duration)}) ==="
 760            )
 761            print(f"チャンク処理時間: {chunk_elapsed:.2f} 秒")
 762            print(chunk_result.text.strip() or "[テキストなし]")
 763            print("=== チャンク結果の終わり ===\n", flush=True)
 764
 765            if progress_callback is not None:
 766                partial_result = ASRResult(
 767                    text="\n".join(parts),
 768                    language=detected_languages[0] if detected_languages else None,
 769                    segments=list(segments),
 770                    engine_metadata={
 771                        "timestamp_items": len(segments),
 772                        "audio_chunks_completed": chunk_index,
 773                        "processed_seconds": core_end,
 774                        "progress_percent": progress_percent,
 775                        "chunk_seconds": chunk_seconds,
 776                        "chunk_overlap_seconds": chunk_overlap,
 777                        "boundary_markers": True,
 778                    },
 779                )
 780                progress_callback(
 781                    partial_result,
 782                    {
 783                        "chunk_index": chunk_index,
 784                        "processed_seconds": core_end,
 785                        "duration_seconds": duration,
 786                        "progress_percent": progress_percent,
 787                        "chunk_elapsed_seconds": chunk_elapsed,
 788                    },
 789                )
 790
 791    detected_language = detected_languages[0] if detected_languages else None
 792    return ASRResult(
 793        text="\n".join(parts),
 794        language=detected_language,
 795        segments=segments,
 796        engine_metadata={
 797            "timestamp_items": len(segments),
 798            "audio_chunks": chunk_index,
 799            "chunk_seconds": chunk_seconds,
 800            "chunk_overlap_seconds": chunk_overlap,
 801            "boundary_markers": True,
 802        },
 803    )
 804
 805
 806def create_backend(args: argparse.Namespace) -> ASRBackend:
 807    """コマンドライン引数に基づいてASRバックエンドインスタンスを作成します。
 808
 809    :param args: コマンドライン引数をパースしたオブジェクト。
 810    :type args: argparse.Namespace
 811    :returns: 初期化されたASRバックエンドのインスタンス。
 812    :rtype: ASRBackend
 813    """
 814    factories = {
 815        "qwen3-asr": lambda: Qwen3ASRBackend(
 816            model_name=args.model,
 817            device=args.device,
 818            dtype_name=args.dtype,
 819            timestamps=bool(args.timestamps),
 820            aligner_name=args.aligner,
 821            max_inference_batch_size=args.max_inference_batch_size,
 822            max_new_tokens=args.max_new_tokens,
 823            attention=args.attention,
 824        )
 825    }
 826    return factories[args.backend]()
 827
 828
 829def build_parser() -> argparse.ArgumentParser:
 830    """コマンドライン引数をパースするためのArgumentParserを作成します。
 831
 832    利用可能なASRバックエンド、モデル、デバイス、データ型、チャンク設定など、
 833    様々なオプションを定義します。
 834
 835    :returns: 設定済みの :class:`argparse.ArgumentParser` オブジェクト。
 836    :rtype: argparse.ArgumentParser
 837    """
 838    parser = argparse.ArgumentParser(
 839        description="Qwen3-ASR音声文字起こしツール(ASRバックエンド拡張対応)"
 840    )
 841    parser.add_argument("infile", help="入力音声ファイル名(glob可)")
 842    parser.add_argument(
 843        "--backend", choices=["qwen3-asr"], default="qwen3-asr",
 844        help="ASRバックエンド (default: qwen3-asr)",
 845    )
 846    parser.add_argument(
 847        "-m", "--model", default="0.6B",
 848        choices=list(MODEL_ALIASES),
 849        help="Qwen3-ASRモデル (default: 0.6B)",
 850    )
 851    parser.add_argument(
 852        "-l", "--lang", default="ja",
 853        help="言語コード/名称。autoまたは空文字で自動判定 (default: ja)",
 854    )
 855    parser.add_argument(
 856        "-d", "--device", default="auto",
 857        help="auto, cpu, cuda, cuda:0など (default: auto)",
 858    )
 859    parser.add_argument(
 860        "--dtype", choices=["auto", "bfloat16", "float16", "float32"],
 861        default="auto", help="推論精度 (default: auto)",
 862    )
 863    parser.add_argument(
 864        "--timestamps", type=int, choices=[0, 1], default=1,
 865        help="ForcedAlignerで時刻を付けるか (default: 1)",
 866    )
 867    parser.add_argument(
 868        "--aligner", default="Qwen/Qwen3-ForcedAligner-0.6B",
 869        help="時刻推定モデル",
 870    )
 871    parser.add_argument(
 872        "--attention", choices=["auto", "sdpa", "eager", "flash_attention_2"],
 873        default="auto", help="Attention実装 (default: auto)",
 874    )
 875    parser.add_argument(
 876        "--max-inference-batch-size", type=int, default=1,
 877        help="推論バッチ上限。小さい値はVRAMを節約 (default: 1)",
 878    )
 879    parser.add_argument(
 880        "--max-new-tokens", type=int, default=4096,
 881        help="生成トークン上限。長時間音声では大きくする (default: 4096)",
 882    )
 883    parser.add_argument(
 884        "--chunk-seconds", type=float, default=240.0,
 885        help="時刻付け時の長時間音声分割秒数。0で分割しない (default: 240)",
 886    )
 887    parser.add_argument(
 888        "--chunk-overlap", type=float, default=10.0,
 889        help="チャンク前後の重複秒数。境界は出力に明示 (default: 10)",
 890    )
 891    parser.add_argument("--outfile1", default="", help="時刻付き出力ファイル")
 892    parser.add_argument("--outfile2", default="", help="本文出力ファイル")
 893    parser.add_argument("--outfile3", default="", help="環境・設定情報JSONファイル")
 894    parser.add_argument(
 895        "--pause", type=int, choices=[0, 1], default=0,
 896        help="終了時にENTERを待つか (default: 0)",
 897    )
 898    return parser
 899
 900
 901def main() -> int:
 902    """Qwen3-ASR音声文字起こしツールのメインエントリポイント。
 903
 904    コマンドライン引数をパースし、指定された音声ファイルを文字起こしします。
 905    環境情報を収集・表示し、ASRモデルをロードして文字起こしを実行し、
 906    結果を複数のファイルに出力します。
 907    複数の入力ファイルが指定された場合、または長い音声ファイルがチャンク分割される場合、
 908    それぞれ個別に処理されます。
 909
 910    :returns: 終了コード。成功時は0、エラー時は1、PyTorch未インストールの場合は2、
 911              ユーザーによる中断時は130を返します。
 912    :rtype: int
 913    """
 914    parser = build_parser()
 915    args = parser.parse_args()
 916    files = sorted(glob.glob(args.infile))
 917    if not files:
 918        parser.error(f"ファイルが見つかりません: {args.infile}")
 919    if len(files) > 1 and (args.outfile1 or args.outfile2 or args.outfile3):
 920        parser.error("複数の入力ファイルで明示的な出力ファイル名は使用できません")
 921
 922    try:
 923        import torch
 924    except ImportError:
 925        print("エラー: PyTorchがこのPython環境にインストールされていません。", file=sys.stderr)
 926        return 2
 927
 928    environment = collect_environment(torch)
 929    print_environment(environment)
 930    language = normalize_language(args.lang)
 931    backend = create_backend(args)
 932
 933    load_started = time.perf_counter()
 934    backend.load()
 935    load_seconds = time.perf_counter() - load_started
 936    print(f"モデルロード時間: {load_seconds:.2f} 秒", flush=True)
 937
 938    for input_path in files:
 939        paths = make_output_paths(input_path, args)
 940        duration = get_audio_duration(input_path)
 941        file_size = os.path.getsize(input_path)
 942
 943        print("\n=== 文字起こし ===")
 944        print(f"入力ファイル     : {input_path}")
 945        print(f"音声長さ       : {format_hms(duration)}")
 946        print(f"言語           : {language or '自動検出'}")
 947        print(f"出力1 (時刻付き) : {paths.timestamped}")
 948        print(f"出力2 (テキスト) : {paths.plain}")
 949        print(f"出力3 (情報)    : {paths.info}")
 950
 951        def save_partial_result(
 952            partial_result: ASRResult,
 953            progress: dict[str, Any],
 954        ) -> None:
 955            """チャンク処理中に中間結果を保存するためのコールバック関数。
 956
 957            :param partial_result: 部分的な文字起こし結果。
 958            :type partial_result: ASRResult
 959            :param progress: 進捗情報を含む辞書。
 960            :type progress: dict[str, Any]
 961            """
 962            write_transcript_outputs(paths, partial_result)
 963            print(
 964                f"部分結果を保存しました: チャンク {progress['chunk_index']} "
 965                f"({progress['progress_percent']:.1f}%) -> "
 966                f"{paths.timestamped}, {paths.plain}",
 967                flush=True,
 968            )
 969
 970        started = time.perf_counter()
 971        result = transcribe_with_optional_chunks(
 972            backend=backend,
 973            audio_path=input_path,
 974            language=language,
 975            duration=duration,
 976            chunk_seconds=args.chunk_seconds,
 977            chunk_overlap=args.chunk_overlap,
 978            use_chunks=bool(args.timestamps),
 979            progress_callback=save_partial_result,
 980        )
 981        elapsed = time.perf_counter() - started
 982        realtime_factor = elapsed / duration if duration and duration > 0 else None
 983
 984        report = {
 985            "environment": environment,
 986            "backend": backend.describe(),
 987            "input": {
 988                "path": os.path.abspath(input_path),
 989                "size_bytes": file_size,
 990                "duration_seconds": duration,
 991                "requested_language": language,
 992            },
 993            "result": {
 994                "detected_language": result.language,
 995                "characters": len(result.text),
 996                "segments": len(result.segments),
 997                "elapsed_seconds": elapsed,
 998                "realtime_factor": realtime_factor,
 999                **result.engine_metadata,
1000            },
1001            "outputs": {key: str(value.resolve()) for key, value in asdict(paths).items()},
1002            "model_load_seconds": load_seconds,
1003        }
1004        write_outputs(paths, result, report)
1005
1006        print(f"検出言語       : {result.language}")
1007        print(f"経過時間       : {elapsed:.2f} 秒")
1008        if realtime_factor is not None:
1009            print(f"リアルタイム係数 : {realtime_factor:.4f}x")
1010        print("\n=== 文字起こしテキスト ===")
1011        print(result.text, flush=True)
1012
1013    return 0
1014
1015
1016if __name__ == "__main__":
1017    exit_code = 1
1018    try:
1019        exit_code = main()
1020    except KeyboardInterrupt:
1021        print("\n中断されました。", file=sys.stderr)
1022        exit_code = 130
1023    except Exception:
1024        traceback.print_exc()
1025        exit_code = 1
1026    finally:
1027        if "--pause" in sys.argv:
1028            try:
1029                # --pause 1 が指定されている場合のみ待機
1030                index = sys.argv.index("--pause")
1031                if index + 1 < len(sys.argv) and sys.argv[index + 1] == "1":
1032                    input("\nENTERキーを押して終了します。\n")
1033            except (ValueError, EOFError):
1034                pass
1035    raise SystemExit(exit_code)