语音转文本程序,带有GUI

📅 2026/8/1 4:49:55 👁️ 阅读次数 📝 编程学习
语音转文本程序,带有GUI

使用效果

使用方法

默认已经安装好Python并在安装时勾选了add to path。

将下面的Python代码保存在任意目录下,文件名设置为v2t.py,在文件管理器的地址栏输入powershell,回车进入当前目录的powershell。在命令窗口输入

python v2t.py

即可开始运行。如果运行的时候powershell报错说下载不下来就需要手动下载,手动下载对应模型以后,将下面代码中已经被注释掉的

#os.environ["HF_HUB_OFFLINE"] = "1"

取消注释。

手动下载模型的话需要保存在程序所在目录的子目录models中,我这里使用的是medium模型,就新建一个models子文件夹,并在这个子文件夹中新建medium文件夹。下面是下载链接

tiny

base

small

medium

largeV3

下载这四个文件就可以了

代码

# -*- coding: utf-8 -*- """Windows 本地音频转文本 GUI。""" from __future__ import annotations import json import os #os.environ["HF_HUB_OFFLINE"] = "1" import queue import threading import traceback from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Optional import tkinter as tk from tkinter import filedialog, messagebox, ttk from tkinter.scrolledtext import ScrolledText APP_TITLE = "音频转文本工具(本地离线版)" SUPPORTED_FILE_TYPES = [ ( "音频和视频", "*.mp3 *.wav *.m4a *.aac *.flac *.ogg *.opus *.wma " "*.mp4 *.mkv *.mov *.avi *.webm *.mpeg *.mpg", ), ("音频文件", "*.mp3 *.wav *.m4a *.aac *.flac *.ogg *.opus *.wma"), ("视频文件", "*.mp4 *.mkv *.mov *.avi *.webm *.mpeg *.mpg"), ("所有文件", "*.*"), ] LANGUAGES = { "自动识别": None, "中文": "zh", "英语": "en", "日语": "ja", "韩语": "ko", "法语": "fr", "德语": "de", "西班牙语": "es", "俄语": "ru", "粤语": "yue", } MODEL_HINTS = { "tiny": "速度最快,准确率较低", "base": "较快,适合清晰短音频", "small": "CPU 推荐,速度与准确率均衡", "medium": "中文准确率更好,但更慢", "large-v3": "准确率优先,需要较强硬件", "turbo": "速度快、准确率高,优先用于显卡", } @dataclass class SegmentRecord: start: float end: float text: str words: Optional[list[dict[str, Any]]] = None def format_timestamp(seconds: float, decimal_marker: str = ",") -> str: seconds = max(0.0, float(seconds)) milliseconds = int(round(seconds * 1000)) hours, milliseconds = divmod(milliseconds, 3_600_000) minutes, milliseconds = divmod(milliseconds, 60_000) secs, milliseconds = divmod(milliseconds, 1_000) return f"{hours:02d}:{minutes:02d}:{secs:02d}{decimal_marker}{milliseconds:03d}" def choose_unique_base( output_dir: Path, stem: str, extensions: list[str], partial: bool, ) -> Path: suffix = "_partial" if partial else "" base = output_dir / f"{stem}{suffix}" counter = 1 while any(base.with_suffix(ext).exists() for ext in extensions): base = output_dir / f"{stem}{suffix}_{counter}" counter += 1 return base def save_outputs( input_path: Path, output_dir: Path, records: list[SegmentRecord], metadata: dict[str, Any], formats: set[str], txt_with_timestamps: bool, partial: bool = False, ) -> list[Path]: output_dir.mkdir(parents=True, exist_ok=True) extension_map = {"txt": ".txt", "srt": ".srt", "vtt": ".vtt", "json": ".json"} extensions = [extension_map[fmt] for fmt in sorted(formats)] base = choose_unique_base(output_dir, input_path.stem, extensions, partial) saved: list[Path] = [] if "txt" in formats: txt_path = base.with_suffix(".txt") if txt_with_timestamps: content = "\n".join( f"[{format_timestamp(item.start, '.')} --> " f"{format_timestamp(item.end, '.')}] {item.text.strip()}" for item in records ) else: content = "\n".join(item.text.strip() for item in records) txt_path.write_text(content.rstrip() + "\n", encoding="utf-8-sig") saved.append(txt_path) if "srt" in formats: srt_path = base.with_suffix(".srt") blocks = [] for index, item in enumerate(records, start=1): blocks.append( f"{index}\n" f"{format_timestamp(item.start, ',')} --> {format_timestamp(item.end, ',')}\n" f"{item.text.strip()}\n" ) srt_path.write_text("\n".join(blocks), encoding="utf-8-sig") saved.append(srt_path) if "vtt" in formats: vtt_path = base.with_suffix(".vtt") blocks = ["WEBVTT\n"] for item in records: blocks.append( f"{format_timestamp(item.start, '.')} --> {format_timestamp(item.end, '.')}\n" f"{item.text.strip()}\n" ) vtt_path.write_text("\n".join(blocks), encoding="utf-8-sig") saved.append(vtt_path) if "json" in formats: json_path = base.with_suffix(".json") payload = { "source_file": str(input_path), "partial": partial, "metadata": metadata, "segments": [asdict(item) for item in records], } json_path.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) saved.append(json_path) return saved class AudioToTextApp: def __init__(self, root: tk.Tk) -> None: self.root = root self.root.title(APP_TITLE) self.root.geometry("980x760") self.root.minsize(860, 650) self.files: list[str] = [] self.message_queue: queue.Queue[tuple[str, Any]] = queue.Queue() self.cancel_event = threading.Event() self.worker: Optional[threading.Thread] = None self.model_var = tk.StringVar(value="small") self.device_var = tk.StringVar(value="自动") self.compute_var = tk.StringVar(value="自动") self.language_var = tk.StringVar(value="自动识别") self.task_var = tk.StringVar(value="转写原语言") self.output_dir_var = tk.StringVar() self.beam_size_var = tk.IntVar(value=5) self.vad_var = tk.BooleanVar(value=True) self.word_timestamp_var = tk.BooleanVar(value=False) self.txt_timestamp_var = tk.BooleanVar(value=False) self.txt_var = tk.BooleanVar(value=True) self.srt_var = tk.BooleanVar(value=True) self.vtt_var = tk.BooleanVar(value=False) self.json_var = tk.BooleanVar(value=False) self.status_var = tk.StringVar(value="请选择音频或视频文件") self.progress_var = tk.DoubleVar(value=0.0) self._build_ui() self._update_model_hint() self.root.after(100, self._poll_messages) self.root.protocol("WM_DELETE_WINDOW", self._on_close) def _build_ui(self) -> None: outer = ttk.Frame(self.root, padding=12) outer.pack(fill=tk.BOTH, expand=True) file_frame = ttk.LabelFrame(outer, text="1. 输入文件", padding=10) file_frame.pack(fill=tk.BOTH, expand=False) file_button_row = ttk.Frame(file_frame) file_button_row.pack(fill=tk.X, pady=(0, 8)) ttk.Button(file_button_row, text="添加文件", command=self._add_files).pack(side=tk.LEFT) ttk.Button(file_button_row, text="移除选中", command=self._remove_selected).pack( side=tk.LEFT, padx=6 ) ttk.Button(file_button_row, text="清空列表", command=self._clear_files).pack(side=tk.LEFT) self.file_listbox = tk.Listbox(file_frame, height=7, selectmode=tk.EXTENDED) self.file_listbox.pack(fill=tk.BOTH, expand=True) output_row = ttk.Frame(file_frame) output_row.pack(fill=tk.X, pady=(8, 0)) ttk.Label(output_row, text="输出目录:").pack(side=tk.LEFT) ttk.Entry(output_row, textvariable=self.output_dir_var).pack( side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 6) ) ttk.Button(output_row, text="选择目录", command=self._choose_output_dir).pack(side=tk.LEFT) settings_frame = ttk.LabelFrame(outer, text="2. 转写设置", padding=10) settings_frame.pack(fill=tk.X, pady=10) row1 = ttk.Frame(settings_frame) row1.pack(fill=tk.X) ttk.Label(row1, text="模型:").pack(side=tk.LEFT) model_combo = ttk.Combobox( row1, textvariable=self.model_var, values=list(MODEL_HINTS.keys()), width=12, state="readonly", ) model_combo.pack(side=tk.LEFT, padx=(4, 14)) model_combo.bind("<<ComboboxSelected>>", lambda _event: self._update_model_hint()) ttk.Label(row1, text="设备:").pack(side=tk.LEFT) ttk.Combobox( row1, textvariable=self.device_var, values=["自动", "CPU", "CUDA"], width=9, state="readonly", ).pack(side=tk.LEFT, padx=(4, 14)) ttk.Label(row1, text="计算精度:").pack(side=tk.LEFT) ttk.Combobox( row1, textvariable=self.compute_var, values=["自动", "int8", "float16", "int8_float16", "float32"], width=12, state="readonly", ).pack(side=tk.LEFT, padx=(4, 14)) ttk.Label(row1, text="Beam:").pack(side=tk.LEFT) ttk.Spinbox( row1, from_=1, to=10, textvariable=self.beam_size_var, width=5, ).pack(side=tk.LEFT, padx=(4, 0)) self.model_hint_label = ttk.Label(settings_frame, text="") self.model_hint_label.pack(anchor=tk.W, pady=(6, 2)) row2 = ttk.Frame(settings_frame) row2.pack(fill=tk.X, pady=(5, 0)) ttk.Label(row2, text="语言:").pack(side=tk.LEFT) ttk.Combobox( row2, textvariable=self.language_var, values=list(LANGUAGES.keys()), width=12, state="readonly", ).pack(side=tk.LEFT, padx=(4, 14)) ttk.Label(row2, text="任务:").pack(side=tk.LEFT) ttk.Combobox( row2, textvariable=self.task_var, values=["转写原语言", "翻译为英文"], width=14, state="readonly", ).pack(side=tk.LEFT, padx=(4, 14)) ttk.Checkbutton(row2, text="过滤静音(VAD)", variable=self.vad_var).pack( side=tk.LEFT, padx=(0, 14) ) ttk.Checkbutton( row2, text="JSON 保存逐字时间戳", variable=self.word_timestamp_var, ).pack(side=tk.LEFT) prompt_row = ttk.Frame(settings_frame) prompt_row.pack(fill=tk.X, pady=(8, 0)) ttk.Label(prompt_row, text="提示词:").pack(side=tk.LEFT) self.prompt_entry = ttk.Entry(prompt_row) self.prompt_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0)) ttk.Label( settings_frame, text="可填写专业术语、人名或公司名,例如:Huang-Rhys、Ni2+、A股、英伟达。", ).pack(anchor=tk.W, pady=(4, 0)) export_frame = ttk.LabelFrame(outer, text="3. 导出格式", padding=10) export_frame.pack(fill=tk.X) ttk.Checkbutton(export_frame, text="TXT", variable=self.txt_var).pack(side=tk.LEFT) ttk.Checkbutton(export_frame, text="SRT 字幕", variable=self.srt_var).pack( side=tk.LEFT, padx=12 ) ttk.Checkbutton(export_frame, text="VTT 字幕", variable=self.vtt_var).pack(side=tk.LEFT) ttk.Checkbutton(export_frame, text="JSON", variable=self.json_var).pack( side=tk.LEFT, padx=12 ) ttk.Checkbutton( export_frame, text="TXT 包含时间戳", variable=self.txt_timestamp_var, ).pack(side=tk.LEFT) action_frame = ttk.Frame(outer) action_frame.pack(fill=tk.X, pady=10) self.start_button = ttk.Button( action_frame, text="开始转写", command=self._start_transcription, ) self.start_button.pack(side=tk.LEFT) self.cancel_button = ttk.Button( action_frame, text="停止", command=self._cancel_transcription, state=tk.DISABLED, ) self.cancel_button.pack(side=tk.LEFT, padx=8) ttk.Label( action_frame, text="首次运行某个模型时需要联网下载;下载完成后可离线使用。", ).pack(side=tk.RIGHT) ttk.Progressbar( outer, variable=self.progress_var, maximum=100, mode="determinate", ).pack(fill=tk.X) ttk.Label(outer, textvariable=self.status_var).pack(anchor=tk.W, pady=(4, 6)) log_frame = ttk.LabelFrame(outer, text="转写日志与实时文本", padding=8) log_frame.pack(fill=tk.BOTH, expand=True) self.log_text = ScrolledText(log_frame, wrap=tk.WORD, height=14) self.log_text.pack(fill=tk.BOTH, expand=True) self.log_text.configure(state=tk.DISABLED) def _update_model_hint(self) -> None: model = self.model_var.get() self.model_hint_label.configure(text=f"模型说明:{MODEL_HINTS.get(model, '')}") def _add_files(self) -> None: selected = filedialog.askopenfilenames( title="选择音频或视频文件", filetypes=SUPPORTED_FILE_TYPES, ) if not selected: return known = set(self.files) for path in selected: if path not in known: self.files.append(path) self.file_listbox.insert(tk.END, path) known.add(path) if not self.output_dir_var.get() and self.files: self.output_dir_var.set(str(Path(self.files[0]).parent)) self.status_var.set(f"已选择 {len(self.files)} 个文件") def _remove_selected(self) -> None: indices = list(self.file_listbox.curselection()) for index in reversed(indices): self.file_listbox.delete(index) del self.files[index] self.status_var.set(f"已选择 {len(self.files)} 个文件") def _clear_files(self) -> None: self.files.clear() self.file_listbox.delete(0, tk.END) self.status_var.set("文件列表已清空") def _choose_output_dir(self) -> None: selected = filedialog.askdirectory(title="选择输出目录") if selected: self.output_dir_var.set(selected) def _selected_formats(self) -> set[str]: formats: set[str] = set() if self.txt_var.get(): formats.add("txt") if self.srt_var.get(): formats.add("srt") if self.vtt_var.get(): formats.add("vtt") if self.json_var.get(): formats.add("json") return formats def _start_transcription(self) -> None: if self.worker and self.worker.is_alive(): return if not self.files: messagebox.showwarning("缺少文件", "请先添加至少一个音频或视频文件。") return missing = [path for path in self.files if not Path(path).is_file()] if missing: messagebox.showerror("文件不存在", f"以下文件不存在:\n{missing[0]}") return formats = self._selected_formats() if not formats: messagebox.showwarning("缺少导出格式", "请至少选择一种导出格式。") return output_dir_text = self.output_dir_var.get().strip() if not output_dir_text: messagebox.showwarning("缺少输出目录", "请选择输出目录。") return output_dir = Path(output_dir_text) try: output_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: messagebox.showerror("目录错误", f"无法创建输出目录:\n{exc}") return try: beam_size = int(self.beam_size_var.get()) if not 1 <= beam_size <= 10: raise ValueError except (ValueError, tk.TclError): messagebox.showwarning("参数错误", "Beam size 必须是 1 到 10 的整数。") return config = { "files": list(self.files), "output_dir": str(output_dir), "formats": formats, "model": self.model_var.get(), "device": self.device_var.get(), "compute": self.compute_var.get(), "language": LANGUAGES.get(self.language_var.get()), "task": "translate" if self.task_var.get() == "翻译为英文" else "transcribe", "beam_size": beam_size, "vad": self.vad_var.get(), "word_timestamps": self.word_timestamp_var.get(), "txt_timestamps": self.txt_timestamp_var.get(), "initial_prompt": self.prompt_entry.get().strip() or None, } self.cancel_event.clear() self.progress_var.set(0) self._clear_log() self._set_running(True) self.status_var.set("正在准备模型……") self.worker = threading.Thread( target=self._worker_main, args=(config,), daemon=True, ) self.worker.start() def _resolve_device_and_compute( self, device_choice: str, compute_choice: str, ) -> tuple[str, str]: if device_choice == "CPU": device = "cpu" elif device_choice == "CUDA": device = "cuda" else: device = "cpu" try: import ctranslate2 if ctranslate2.get_cuda_device_count() > 0: device = "cuda" except Exception: device = "cpu" if compute_choice != "自动": compute_type = compute_choice else: compute_type = "float16" if device == "cuda" else "int8" return device, compute_type def _load_model(self, config: dict[str, Any]): try: from faster_whisper import WhisperModel except ImportError as exc: raise RuntimeError( "尚未安装 faster-whisper。\n" "请执行:python -m pip install faster-whisper" ) from exc device, compute_type = self._resolve_device_and_compute( config["device"], config["compute"], ) model_name = config["model"] # 使用 Python 文件所在目录,而不是 PowerShell 当前目录 program_dir = Path(__file__).resolve().parent local_model_dir = program_dir / "models" / model_name required_files = [ local_model_dir / "model.bin", local_model_dir / "config.json", local_model_dir / "tokenizer.json", ] missing_files = [ str(path) for path in required_files if not path.is_file() ] if missing_files: raise RuntimeError( "本地模型不完整,程序已禁止联网下载。\n\n" f"模型目录:{local_model_dir}\n\n" "缺少文件:\n" + "\n".join(missing_files) ) model_source = str(local_model_dir.resolve()) self.message_queue.put( ( "log", f"使用本地模型目录:{model_source}\n" f"加载设备:{device};计算精度:{compute_type}\n", ) ) try: model = WhisperModel( model_source, device=device, compute_type=compute_type, local_files_only=True, ) return model, device, compute_type except Exception as first_exc: # 只有选择“自动”且 CUDA 真正加载失败时,才回退 CPU if config["device"] == "自动" and device == "cuda": self.message_queue.put( ( "log", "本地模型文件读取成功,但 CUDA 初始化失败," "自动切换为 CPU/int8。\n" f"CUDA 错误:{first_exc}\n", ) ) try: model = WhisperModel( model_source, device="cpu", compute_type="int8", local_files_only=True, ) return model, "cpu", "int8" except Exception as cpu_exc: raise RuntimeError( "本地模型使用 CUDA 和 CPU 均加载失败。\n\n" f"模型目录:{model_source}\n\n" f"CUDA 错误:{first_exc}\n\n" f"CPU 错误:{cpu_exc}" ) from cpu_exc raise RuntimeError( "本地模型加载失败。\n\n" f"模型目录:{model_source}\n" f"设备:{device}\n" f"计算精度:{compute_type}\n\n" f"错误:{first_exc}" ) from first_exc def _worker_main(self, config: dict[str, Any]) -> None: try: model, actual_device, actual_compute = self._load_model(config) total_files = len(config["files"]) output_dir = Path(config["output_dir"]) for file_index, file_name in enumerate(config["files"]): if self.cancel_event.is_set(): break input_path = Path(file_name) self.message_queue.put( ("status", f"正在处理 {file_index + 1}/{total_files}:{input_path.name}") ) self.message_queue.put( ("log", f"\n{'=' * 70}\n文件:{input_path}\n") ) transcribe_kwargs: dict[str, Any] = { "language": config["language"], "task": config["task"], "beam_size": config["beam_size"], "vad_filter": config["vad"], "word_timestamps": config["word_timestamps"], "initial_prompt": config["initial_prompt"], "condition_on_previous_text": True, } if config["vad"]: transcribe_kwargs["vad_parameters"] = { "min_silence_duration_ms": 500 } segments_generator, info = model.transcribe( str(input_path), **transcribe_kwargs, ) duration = max(float(getattr(info, "duration", 0.0) or 0.0), 0.001) language = getattr(info, "language", None) language_probability = getattr(info, "language_probability", None) lang_message = f"识别语言:{language or '未知'}" if isinstance(language_probability, (int, float)): lang_message += f"(置信度 {language_probability:.1%})" self.message_queue.put(("log", lang_message + "\n")) records: list[SegmentRecord] = [] cancelled_during_file = False for segment in segments_generator: if self.cancel_event.is_set(): cancelled_during_file = True break words = None if config["word_timestamps"] and getattr(segment, "words", None): words = [ { "start": float(word.start), "end": float(word.end), "word": word.word, "probability": float(word.probability), } for word in segment.words ] record = SegmentRecord( start=float(segment.start), end=float(segment.end), text=segment.text.strip(), words=words, ) records.append(record) self.message_queue.put( ( "segment", f"[{format_timestamp(record.start, '.')} → " f"{format_timestamp(record.end, '.')}] {record.text}\n", ) ) current_fraction = min(record.end / duration, 1.0) total_progress = ( (file_index + current_fraction) / total_files * 100.0 ) self.message_queue.put(("progress", total_progress)) metadata = { "model": config["model"], "device": actual_device, "compute_type": actual_compute, "detected_language": language, "language_probability": language_probability, "duration_seconds": duration, "task": config["task"], "beam_size": config["beam_size"], "vad_filter": config["vad"], } if records: saved_paths = save_outputs( input_path=input_path, output_dir=output_dir, records=records, metadata=metadata, formats=config["formats"], txt_with_timestamps=config["txt_timestamps"], partial=cancelled_during_file, ) self.message_queue.put( ( "log", "已保存:\n" + "\n".join(f" {path}" for path in saved_paths) + "\n", ) ) elif not cancelled_during_file: self.message_queue.put(("log", "未识别到有效语音,未生成输出文件。\n")) self.message_queue.put( ("progress", (file_index + 1) / total_files * 100.0) ) if cancelled_during_file: break if self.cancel_event.is_set(): self.message_queue.put(("finished", ("cancelled", None))) else: self.message_queue.put(("finished", ("success", config["output_dir"]))) except Exception as exc: error_detail = traceback.format_exc() self.message_queue.put(("log", f"\n错误详情:\n{error_detail}\n")) self.message_queue.put(("finished", ("error", str(exc)))) def _cancel_transcription(self) -> None: if self.worker and self.worker.is_alive(): self.cancel_event.set() self.status_var.set("正在停止;当前推理步骤结束后生效……") self.cancel_button.configure(state=tk.DISABLED) def _poll_messages(self) -> None: try: while True: message_type, payload = self.message_queue.get_nowait() if message_type in {"log", "segment"}: self._append_log(str(payload)) elif message_type == "status": self.status_var.set(str(payload)) elif message_type == "progress": self.progress_var.set(float(payload)) elif message_type == "finished": result, detail = payload self._set_running(False) if result == "success": self.progress_var.set(100) self.status_var.set("全部转写完成") messagebox.showinfo( "完成", f"全部文件转写完成。\n输出目录:\n{detail}", ) elif result == "cancelled": self.status_var.set("任务已停止;已识别部分会保存为 partial 文件") else: self.status_var.set("转写失败") messagebox.showerror( "转写失败", f"{detail}\n\n完整错误信息已写入日志。", ) except queue.Empty: pass finally: self.root.after(100, self._poll_messages) def _append_log(self, text: str) -> None: self.log_text.configure(state=tk.NORMAL) self.log_text.insert(tk.END, text) self.log_text.see(tk.END) self.log_text.configure(state=tk.DISABLED) def _clear_log(self) -> None: self.log_text.configure(state=tk.NORMAL) self.log_text.delete("1.0", tk.END) self.log_text.configure(state=tk.DISABLED) def _set_running(self, running: bool) -> None: self.start_button.configure(state=tk.DISABLED if running else tk.NORMAL) self.cancel_button.configure(state=tk.NORMAL if running else tk.DISABLED) def _on_close(self) -> None: if self.worker and self.worker.is_alive(): should_close = messagebox.askyesno( "确认退出", "转写尚未结束。退出会中止当前任务,确定退出吗?", ) if not should_close: return self.cancel_event.set() self.root.destroy() def main() -> None: root = tk.Tk() try: if os.name == "nt": from ctypes import windll windll.shcore.SetProcessDpiAwareness(1) except Exception: pass AudioToTextApp(root) root.mainloop() if __name__ == "__main__": main()