语音转文本程序,带有GUI

发布时间:2026/8/1 4:27:54
语音转文本程序,带有GUI 使用效果使用方法默认已经安装好Python并在安装时勾选了add to path。将下面的Python代码保存在任意目录下文件名设置为v2t.py在文件管理器的地址栏输入powershell回车进入当前目录的powershell。在命令窗口输入python v2t.py即可开始运行。如果运行的时候powershell报错说下载不下来就需要手动下载手动下载对应模型以后将下面代码中已经被注释掉的#os.environ[HF_HUB_OFFLINE] 1取消注释。手动下载模型的话需要保存在程序所在目录的子目录models中我这里使用的是medium模型就新建一个models子文件夹并在这个子文件夹中新建medium文件夹。下面是下载链接tinybasesmallmediumlargeV3下载这四个文件就可以了代码# -*- 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(parentsTrue, exist_okTrue) 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, encodingutf-8-sig) saved.append(txt_path) if srt in formats: srt_path base.with_suffix(.srt) blocks [] for index, item in enumerate(records, start1): 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), encodingutf-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), encodingutf-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_asciiFalse, indent2), encodingutf-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(valuesmall) 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(value5) self.vad_var tk.BooleanVar(valueTrue) self.word_timestamp_var tk.BooleanVar(valueFalse) self.txt_timestamp_var tk.BooleanVar(valueFalse) self.txt_var tk.BooleanVar(valueTrue) self.srt_var tk.BooleanVar(valueTrue) self.vtt_var tk.BooleanVar(valueFalse) self.json_var tk.BooleanVar(valueFalse) self.status_var tk.StringVar(value请选择音频或视频文件) self.progress_var tk.DoubleVar(value0.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, padding12) outer.pack(filltk.BOTH, expandTrue) file_frame ttk.LabelFrame(outer, text1. 输入文件, padding10) file_frame.pack(filltk.BOTH, expandFalse) file_button_row ttk.Frame(file_frame) file_button_row.pack(filltk.X, pady(0, 8)) ttk.Button(file_button_row, text添加文件, commandself._add_files).pack(sidetk.LEFT) ttk.Button(file_button_row, text移除选中, commandself._remove_selected).pack( sidetk.LEFT, padx6 ) ttk.Button(file_button_row, text清空列表, commandself._clear_files).pack(sidetk.LEFT) self.file_listbox tk.Listbox(file_frame, height7, selectmodetk.EXTENDED) self.file_listbox.pack(filltk.BOTH, expandTrue) output_row ttk.Frame(file_frame) output_row.pack(filltk.X, pady(8, 0)) ttk.Label(output_row, text输出目录).pack(sidetk.LEFT) ttk.Entry(output_row, textvariableself.output_dir_var).pack( sidetk.LEFT, filltk.X, expandTrue, padx(5, 6) ) ttk.Button(output_row, text选择目录, commandself._choose_output_dir).pack(sidetk.LEFT) settings_frame ttk.LabelFrame(outer, text2. 转写设置, padding10) settings_frame.pack(filltk.X, pady10) row1 ttk.Frame(settings_frame) row1.pack(filltk.X) ttk.Label(row1, text模型).pack(sidetk.LEFT) model_combo ttk.Combobox( row1, textvariableself.model_var, valueslist(MODEL_HINTS.keys()), width12, statereadonly, ) model_combo.pack(sidetk.LEFT, padx(4, 14)) model_combo.bind(ComboboxSelected, lambda _event: self._update_model_hint()) ttk.Label(row1, text设备).pack(sidetk.LEFT) ttk.Combobox( row1, textvariableself.device_var, values[自动, CPU, CUDA], width9, statereadonly, ).pack(sidetk.LEFT, padx(4, 14)) ttk.Label(row1, text计算精度).pack(sidetk.LEFT) ttk.Combobox( row1, textvariableself.compute_var, values[自动, int8, float16, int8_float16, float32], width12, statereadonly, ).pack(sidetk.LEFT, padx(4, 14)) ttk.Label(row1, textBeam).pack(sidetk.LEFT) ttk.Spinbox( row1, from_1, to10, textvariableself.beam_size_var, width5, ).pack(sidetk.LEFT, padx(4, 0)) self.model_hint_label ttk.Label(settings_frame, text) self.model_hint_label.pack(anchortk.W, pady(6, 2)) row2 ttk.Frame(settings_frame) row2.pack(filltk.X, pady(5, 0)) ttk.Label(row2, text语言).pack(sidetk.LEFT) ttk.Combobox( row2, textvariableself.language_var, valueslist(LANGUAGES.keys()), width12, statereadonly, ).pack(sidetk.LEFT, padx(4, 14)) ttk.Label(row2, text任务).pack(sidetk.LEFT) ttk.Combobox( row2, textvariableself.task_var, values[转写原语言, 翻译为英文], width14, statereadonly, ).pack(sidetk.LEFT, padx(4, 14)) ttk.Checkbutton(row2, text过滤静音VAD, variableself.vad_var).pack( sidetk.LEFT, padx(0, 14) ) ttk.Checkbutton( row2, textJSON 保存逐字时间戳, variableself.word_timestamp_var, ).pack(sidetk.LEFT) prompt_row ttk.Frame(settings_frame) prompt_row.pack(filltk.X, pady(8, 0)) ttk.Label(prompt_row, text提示词).pack(sidetk.LEFT) self.prompt_entry ttk.Entry(prompt_row) self.prompt_entry.pack(sidetk.LEFT, filltk.X, expandTrue, padx(5, 0)) ttk.Label( settings_frame, text可填写专业术语、人名或公司名例如Huang-Rhys、Ni2、A股、英伟达。, ).pack(anchortk.W, pady(4, 0)) export_frame ttk.LabelFrame(outer, text3. 导出格式, padding10) export_frame.pack(filltk.X) ttk.Checkbutton(export_frame, textTXT, variableself.txt_var).pack(sidetk.LEFT) ttk.Checkbutton(export_frame, textSRT 字幕, variableself.srt_var).pack( sidetk.LEFT, padx12 ) ttk.Checkbutton(export_frame, textVTT 字幕, variableself.vtt_var).pack(sidetk.LEFT) ttk.Checkbutton(export_frame, textJSON, variableself.json_var).pack( sidetk.LEFT, padx12 ) ttk.Checkbutton( export_frame, textTXT 包含时间戳, variableself.txt_timestamp_var, ).pack(sidetk.LEFT) action_frame ttk.Frame(outer) action_frame.pack(filltk.X, pady10) self.start_button ttk.Button( action_frame, text开始转写, commandself._start_transcription, ) self.start_button.pack(sidetk.LEFT) self.cancel_button ttk.Button( action_frame, text停止, commandself._cancel_transcription, statetk.DISABLED, ) self.cancel_button.pack(sidetk.LEFT, padx8) ttk.Label( action_frame, text首次运行某个模型时需要联网下载下载完成后可离线使用。, ).pack(sidetk.RIGHT) ttk.Progressbar( outer, variableself.progress_var, maximum100, modedeterminate, ).pack(filltk.X) ttk.Label(outer, textvariableself.status_var).pack(anchortk.W, pady(4, 6)) log_frame ttk.LabelFrame(outer, text转写日志与实时文本, padding8) log_frame.pack(filltk.BOTH, expandTrue) self.log_text ScrolledText(log_frame, wraptk.WORD, height14) self.log_text.pack(filltk.BOTH, expandTrue) self.log_text.configure(statetk.DISABLED) def _update_model_hint(self) - None: model self.model_var.get() self.model_hint_label.configure(textf模型说明{MODEL_HINTS.get(model, )}) def _add_files(self) - None: selected filedialog.askopenfilenames( title选择音频或视频文件, filetypesSUPPORTED_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(parentsTrue, exist_okTrue) 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( targetself._worker_main, args(config,), daemonTrue, ) 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, devicedevice, compute_typecompute_type, local_files_onlyTrue, ) 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 fCUDA 错误{first_exc}\n, ) ) try: model WhisperModel( model_source, devicecpu, compute_typeint8, local_files_onlyTrue, ) return model, cpu, int8 except Exception as cpu_exc: raise RuntimeError( 本地模型使用 CUDA 和 CPU 均加载失败。\n\n f模型目录{model_source}\n\n fCUDA 错误{first_exc}\n\n fCPU 错误{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( startfloat(segment.start), endfloat(segment.end), textsegment.text.strip(), wordswords, ) 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_pathinput_path, output_diroutput_dir, recordsrecords, metadatametadata, formatsconfig[formats], txt_with_timestampsconfig[txt_timestamps], partialcancelled_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(statetk.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(statetk.NORMAL) self.log_text.insert(tk.END, text) self.log_text.see(tk.END) self.log_text.configure(statetk.DISABLED) def _clear_log(self) - None: self.log_text.configure(statetk.NORMAL) self.log_text.delete(1.0, tk.END) self.log_text.configure(statetk.DISABLED) def _set_running(self, running: bool) - None: self.start_button.configure(statetk.DISABLED if running else tk.NORMAL) self.cancel_button.configure(statetk.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()