用Python+Whisper构建本地音频视频转文字转换器

发布时间:2026/10/25 8:05:27
用Python+Whisper构建本地音频视频转文字转换器

简介:这是一份基于Whisper模型的音频视频转文字工具源码,适合需要批量转录音视频内容的开发者、研究者,以及正在学习语音识别技术的Python使用者。项目结构完整,包含UI入口、核心转写逻辑、公共工具模块、依赖说明和演示截图,能从导入音视频到输出文本展示出完整的调用链路。其中main.py为程序入口,tools模块处理音频预处理,common_util提供通用辅助函数;readme.txt与requirements.txt说明运行环境和依赖,便于快速在本地搭建环境并启动。压缩包共23个文件,以Python源码(负责音频读取与模型调用)、XML配置(界面与工程参数)、pyc编译缓存、TXT/readme说明文档为主要类型,整体仅156KB,体积轻量、目录清晰。目前已有409人学习下载,既可在本地运行体验Whisper的转写效果,也可作为二次开发、课程设计或语音转写项目入门的参考。

1. Whisper 把音频转文字不是新事,为什么还要自己写一套 Python 源码?

在线语音转文字工具遍地都是,但一旦你手上有一批几十个小时的访谈录音、课程录像或者会议纪要,逐条上传到网页再等结果,会让人怀疑人生。基于 Whisper 模型做本地音频视频转文字转换器,解决的正是这个痛点:不依赖网络、不按分钟计费、隐私数据不出门,还能把视频直接拖进去出带时间戳的逐字稿。这套 Python 设计源码的核心,就是帮你把“视频→音频→文字”这条链路封装成命令行或服务,批量跑、断点续传、随时换模型。适合谁?文案工作者、做播客剪辑的后期、需要整理采访素材的研究者,以及想低成本给视频加字幕的开发者。它能省下的不只是钱,更是不确定性和等待。

2. 先把模型选对:Whisper 的模型尺寸、运行环境与最小调用

2.1 Whisper 五种模型尺寸怎么选:速度和精度的取舍

Whisper 官方把模型按参数规模分成了 tiny、base、small、medium、large 五档,外加一个 large-v3 的迭代版本。很多人第一次跑就直接上 large,结果发现 4G 显存的卡根本放不下,或者转一段 10 分钟的视频要等半小时。选模型不是在选“最好的”,而是在选“当前硬件能接受的最高精度”。

我一般这样拍板:CPU 机器或者显存小于 4G,用 base 或 small;8G 显存以上再考虑 medium;如果想做字幕级质量且不着急,才选 large。下图是官方 README 里那个耳熟能详的对照表的实际体感:tiny 转英文快得离谱,但中文识别基本没法用;base 能听清普通话短句,但遇到方言、专业名词、背景音乐就开始胡说;small 是 CPU 能跑的性价比上限,中文错字率可控;medium 在 GPU 上能处理大部分音视频转文字场景,但对于“的、地、得”这类同音字还是偶尔翻车;large 适合追求逐字稿质量、愿意等的人。

实际项目里最稳的做法是:用 small 先跑通全流程,确认代码、依赖、输出格式都没问题,再针对具体语料测一下 medium 和 large 的识别差异。不要一上来就追求最优模型,否则你会花一晚上在下载权重和调显存上,而不是在真正要转的文字上。

2.2 本地部署 whisper 服务:Python 环境与 torch 安装要点

要把 Whisper 在本地部署成服务,第一步不是 pip install openai-whisper,而是先把 Python 和 PyTorch 的版本对齐。Whisper 依赖 torch,而 torch 又强依赖 CUDA 版本。很多“装了库但 import 就崩”的问题,几乎都是 torch 装成了 CPU 版,或者 CUDA 11 的 torch 配了 CUDA 12 的驱动。

常见的做法是用 conda 建一个干净环境:

conda create -n whisper python=3.10 -y conda activate whisper pip install --upgrade pip # 如果只用 CPU,装 CPU 版 torch,体积小且不会报 CUDA 错 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu # 如果有 NVIDIA 显卡,先查自己的 CUDA 版本,再装对应版本 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install openai-whisper

这段命令的逻辑是:先隔离环境避免污染系统 Python,再根据硬件选择 torch 的安装源。注意--index-url是指定 PyTorch 官方源,不是下载页,而是让 pip 从这个源里拉对应 CUDA 版本的 wheel 包。如果是 macOS,直接pip install torch即可,但 MPS 加速在 Whisper 上仍有兼容问题,建议还是当 CPU 用。

参数说明:python=3.10是因为 torch 对 3.11/3.12 的支持曾有一段时间不完善,虽然现在问题少了,但 3.10 仍然是社区里最稳的选择。cu118表示 CUDA 11.8 的预编译包,如果你的显卡驱动支持 CUDA 12,也可以换cu121,但必须保证驱动版本足够新。装完这些后,跑python -c "import whisper; print(whisper.__version__)"能正常输出,说明基础环境已经通了。

2.3 验证模型跑通的第一段代码:transcribe() 最小调用

环境就绪后,先不要写复杂的转换器,用一段最小代码验证模型能加载、能推理、能输出文字。这一步的目的不是处理业务,而是确认整个链路没有硬件层面的黑匣子问题。

import whisper # 加载模型,返回的是一个 Whisper 实例 model = whisper.load_model("small") # 转写一个音频文件 result = model.transcribe("test.mp3", language="zh", fp16=False) # 打印识别结果 print(result["text"])

这段代码看似简单,但有两个参数值得说明。language="zh"是让模型固定用中文解码,而不是自动检测语种。自动检测对纯英文没问题,但中文语音经常被误判成日文或粤语,导致输出一堆汉字夹杂假名。fp16=False强制关闭半精度推理,这在 CPU 上是必须的,否则会报数据类型错误;在 GPU 上如果要开启半精度提速,可以改成fp16=True,但前提是 CUDA 版本和 torch 都支持。

跑通这段代码后,你已经有了一个“能用”的 Whisper 转文字原型。但离“音频视频转文字转换器”还差两步:一是让程序能直接接收视频文件,二是把转写流程封装成可复用的代码结构。下一章我会把这两步补上。

3. 从视频到文字:ffmpeg 音频抽取与 Whisper 转录的完整流程

3.1 为什么一定要先抽音频:Whisper 不直接读视频文件

Whisper 的transcribe()虽然接受文件路径作为参数,但实际上它只认音频格式。如果你直接传一个.mp4进去,OpenAI 的官方代码会尝试用 ffmpeg 读取音频流,但前提是你的系统里装了 ffmpeg 并且路径可用。更麻烦的是,直接传视频时,Whisper 内部会把整个视频文件当作媒体流处理,遇到带画面压缩的复杂视频,读取耗时剧增,甚至出现“音频读出来了但画面解码卡死”的情况。

所以我在设计转换器时,总把“音频抽取”做成独立的一步,而不是依赖 Whisper 内部隐式调用 ffmpeg。这样做有三个好处:第一,抽取后的音频可以统一为 16kHz 单声道 wav,这是 Whisper 官方推荐的输入格式,识别效果最稳定;第二,抽音频和转文字可以分开调试,哪个环节失败一眼就能看到;第三,后续如果换成其他语音识别引擎,音频缓存还能复用。

3.2 用 ffmpeg 把 mp4 转成 16kHz wav:命令与参数说明

ffmpeg 是音频视频处理的事实标准,但很多人只是听说过,没认真理解过它的参数。转换器里最常用的命令是这一个:

ffmpeg -i input.mp4 -vn -ac 1 -ar 16000 -f wav output.wav

-i指定输入文件,-vn表示不要视频流,只处理音频。-ac 1把声道合并成单声道,这非常重要:Whisper 对立体声处理不好,如果两个声道内容不一致,会识别出重复文本。-ar 16000重采样到 16000 Hz,这是语音识别领域最常见的采样率,既不丢失语音细节,又不会让计算量白白翻倍。-f wav强制输出 wav 封装格式,避免生成其他容器导致后续读取失败。

在 Python 里调用这条命令,我不会用os.system,而是用subprocess.run并捕获错误输出,因为 ffmpeg 的报错信息经常能直接告诉你音频流损坏还是编码不支持:

import subprocess def extract_audio(video_path, audio_path): cmd = [ "ffmpeg", "-i", video_path, "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", "-y", audio_path ] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError(f"ffmpeg 抽取失败: {proc.stderr}") return audio_path

这里的重点是把 stderr 捕获出来而不是扔到终端,否则你批量处理 100 个文件时,哪个文件失败了根本不知道。-y表示覆盖输出文件而不询问,这在循环处理时能避免进程挂起等待输入。

3.3 Python 里整合音频提取和转录:设计一个转换器类

有了音频抽取和 Whisper 转录这两个能力,就可以把它们组合成一个转换器类。我设计这个类时,要求它既能处理单个文件,也能处理整个目录;既能输出纯文本,也能输出带时间戳的 srt 字幕。

import whisper from pathlib import Path class AudioVideoTranscriber: def __init__(self, model_name="small", device="cpu"): self.model = whisper.load_model(model_name, device=device) def transcribe_file(self, video_path, output_dir="output"): video_path = Path(video_path) output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) # 抽音频 audio_path = output_dir / (video_path.stem + ".wav") extract_audio(str(video_path), str(audio_path)) # 转文字 result = self.model.transcribe(str(audio_path), language="zh", fp16=False) # 写文本文件 (output_dir / (video_path.stem + ".txt")).write_text( result["text"], encoding="utf-8" ) # 写 srt 字幕 self._write_srt(result["segments"], output_dir / (video_path.stem + ".srt")) return result["text"] def _write_srt(self, segments, srt_path): lines = [] for i, seg in enumerate(segments, start=1): start = self._format_srt_time(seg["start"]) end = self._format_srt_time(seg["end"]) lines.append(f"{i}\n{start} --> {end}\n{seg['text'].strip()}\n") srt_path.write_text("\n".join(lines), encoding="utf-8") def _format_srt_time(self, seconds): hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) millis = int((seconds - int(seconds)) * 1000) return f"{hours:02}:{minutes:02}:{secs:02},{millis:03}"

这个类的设计思路是:构造函数只负责加载模型,避免每个文件重复加载;transcribe_file负责整个流程编排;辅助方法_write_srt把 Whisper 返回的 segments 转成标准字幕格式。segments是 Whisper 里带时间边界的文本块,每个元素有start、end、text三个关键字段。_format_srt_time把秒数转换成小时:分钟:秒,毫秒格式,因为 srt 的时间戳要求毫秒用逗号分隔,不是小数点,这是字幕软件读取时最容易出错的地方。

实际使用中,这个类还能继续扩展,比如加入language参数、task参数(转写或翻译),但核心的编排逻辑已经足够撑起一个可用的转换器。

4. 把源码拆开看:一个可扩展的音频视频转文字转换器应该有哪些模块

4.1 模块划分:文件发现、音频提取、转录、结果导出

上一章的转换器类能工作,但如果把源码直接堆在一个文件里,后面加需求就会很痛苦。设计源码时,我会按职责分成四个模块,彼此之间通过函数或类接口调用。最常见做法是:

  • 文件发现模块:递归扫描目录,找出所有 mp4、mkv、avi、mov、flv、mp3、wav 等音视频文件,并过滤掉已经处理过的。
  • 音频提取模块:统一用 ffmpeg 把各类视频抽成 16kHz 单声道 wav,对音频文件则直接复制或转码。
  • 转录模块:加载 Whisper 模型,对 wav 文件执行transcribe,返回原始文本和 segments。
  • 结果导出模块:把文本写成 txt、srt、vtt,或者拼接成 JSON 供后续流程使用。

这种拆法的好处是:如果你要换掉 Whisper,只需要重写转录模块;如果你要加人声分离,只需在音频提取和转录之间插入一步。很多现成的开源项目就是这个结构,只不过它们把命令行参数、日志、批量队列也加了进来,复杂度陡增。

为了保持源码可读,我习惯用dataclass定义中间数据,让每个模块的输入输出清晰可见:

from dataclasses import dataclass from pathlib import Path @dataclass class MediaFile: path: Path kind: str # "video" or "audio" duration: float @property def stem(self): return self.path.stem @dataclass class TranscriptResult: text: str segments: list language: str

MediaFile负责描述一个待处理文件,TranscriptResult负责携带转写结果。这样在编写批量流程时,你可以把MediaFile列表先构建好,再逐个处理,避免在函数调用里用video_path、audio_path、result_text这种散装变量来回传。

4.2 关键参数 batch_size 与 language 的实战设置

Whisper 的transcribe参数里,除了模型名,batch_size、language、fp16、condition_on_previous_text这 4 个是最常调错的。我先说batch_size:它控制的是模型内部对音频分块并行解码的块数,不是一次处理几个文件。默认值是 8,但在 CPU 上,batch_size=8反而会因为内存占用过高而变慢;我建议 CPU 设 2 或 4,GPU 设 8 或 16。

language前面已经说过,必须有意识地设置。另一个参数condition_on_previous_text更隐蔽:默认是 True,意思是模型会参考上一段的文本生成下一段,这本来能提升连贯性,但在有背景音乐或多人讲话时,它会导致“重复生成同一句话”的循环。如果发现转录文本出现大段重复,就把这个参数设为 False,代价是长音频的上下文连贯性稍微下降。

实战中我一般这样设置:

result = self.model.transcribe( audio_path, language="zh", fp16=torch.cuda.is_available(), batch_size=4 if torch.cuda.is_available() else 2, condition_on_previous_text=True )

注意fp16直接关联torch.cuda.is_available(),这比硬编码True或者False更可靠,因为同一套源码在不同机器上跑,不需要改代码。batch_size的赋值逻辑同理,用设备类型自动决定,避免在一台 8G 显存的卡上因为 batch_size 过大而 OOM。

4.3 输出格式选择:txt/srt/vtt 与时间戳对齐

转换器最终要给用户什么格式,会直接影响一个项目能不能被接受。纯 txt 最简单,但丢了时间信息,无法对应到视频画面;srt 是各大播放器和剪辑软件通用的字幕格式;vtt 是 Web 字幕格式,用于网页播放器。设计源码时,我会做一个导出函数,支持这三种格式,并用一个参数控制:

def export_result(result: TranscriptResult, output_stem: str, fmt: str = "srt"): if fmt == "txt": text = result.text elif fmt == "srt": text = segments_to_srt(result.segments) elif fmt == "vtt": text = segments_to_vtt(result.segments) else: raise ValueError(f"不支持的输出格式: {fmt}") Path(output_stem + "." + fmt).write_text(text, encoding="utf-8") return text

segments_to_srt和segments_to_vtt的区别在于时间戳格式:srt 用逗号分隔毫秒,vtt 用小数点,并且 vtt 第一行必须写WEBVTT。如果不注意这个细节,生成的文件在播放器里会直接报格式错误。我在做字幕导出时,常常需要处理时间戳对齐问题:Whisper 返回的 segment 时间戳是基于整段音频的绝对时间,但如果你的视频在抽音频时做了裁剪,必须对时间做偏移校正。这个容易踩坑,我在下一章细说。

5. 避坑与常见问题:Whisper 转录翻车的 5 条血泪经验

5.1 中文识别出现繁体字或夹杂英文

现象:一段普通话录音,转出来的文字一半是简体,一半是繁体,偶尔还蹦出几个英文字母。原因分析:Whisper 的训练数据里中文语料繁简混杂,且语言检测阶段可能把粤语或文语混合当成目标。解决方法:在transcribe中固定language="zh",并将initial_prompt设置成“以下是简体中文普通话的转录文本”,能明显减少繁体和英文残留:

result = model.transcribe( audio_path, language="zh", initial_prompt="以下是简体中文普通话的转录文本。" )

这个技巧属于“玄学”派别,但对 OpenAI 的多语言模型确实起效,原理是给解码器一个先验语境,让它偏向简体中文输出。

5.2 GPU 显存足够却报 CUDA out of memory

现象:显卡是 12G 显存,加载 large 模型也成功,但一跑transcribe就报CUDA out of memory。原因分析:显存足够加载模型,但batch_size或fp16设置不当,推理时的激活值把剩余显存吃满了。解决方法:先设置torch.cuda.empty_cache(),再把batch_size降到 4 或 2,并开启fp16=True减少一半显存占用。如果仍然 OOM,检查是否有其他进程占用显存,用nvidia-smi看一眼。

5.3 视频音轨是立体声导致转录结果重复

现象:同一个视频转出来的文字,每一句话都出现两遍,像是“你好你好”。原因分析:Whisper 内部对立体声音频没有自动合并,左右声道相同语音被识别成了两条。解决方法:在 ffmpeg 抽取音频时务必加上-ac 1,强制转成单声道。如果你的流程已经生成 wav,可以重新执行抽取命令覆盖原文件,而不是在代码里做声道转换,因为-ac 1在 ffmpeg 里是最可靠的处理。

5.4 transcribe 直接把整个视频读入内存导致卡死

现象:处理一个 2 小时电影,程序在读取阶段就占用十几 G 内存,然后系统变得卡顿。原因分析:Whisper 官方代码会用ffmpeg把音频加载到内存,如果采样率高、声道多,内存占用会超出预期。解决方法:抽音频时输出 wav 到磁盘,并检查文件大小:一个 2 小时的 16kHz 单声道 wav 大约 230MB,如果你抽出来的 wav 有 1GB,说明采样率或声道没设置对。另外,Whisper 对长音频会做内部切片,但切片前的完整音频已常驻内存,所以不要用超大内存的机器做这个,32G 内存足够 4 小时音频。

5.5 长音频转录半小时后进程被杀

现象:处理一个 90 分钟的播客,跑到一半进程消失,没有任何报错。原因分析:常见于 OOM killer 或其他进程抢占资源,也可能是transcribe回调函数里累积了太多段数据。解决方法:给 Python 进程设置ulimit或malloc调试不现实,最好的是把长音频切分成 20-30 分钟的片段,逐段转录后合并 segments。Whisper 生态里有人写了long_form支持,但官方transcribe其实内部已经分段,所以进程被杀大概率是其他问题。用dmesg | tail -20查看内核日志,就能看到是不是被 OOM killer 杀的。我碰到的案例是,系统开了 docker 导致内存碎片化,关掉其他服务后问题消失。

6. 让转换器更实用:批量任务、断点续传与模型热切换

到了这一步,单文件的转换器已经能跑,但要应对真实工作流,还得补三个功能:批量处理、断点续传、按需换模型。

批量处理可以用Path.glob或os.walk收集文件,然后用concurrent.futures.ThreadPoolExecutor并行处理。这里要注意,Whisper 模型不是线程安全的,每个线程需要独立的模型实例,显存会成倍上升。我通常用进程池而不是线程池,每个进程只加载一次模型,处理完一个文件后再取下一个。

断点续传是批量任务的后悔药。实现起来不复杂:在每次处理完一个文件后,把它的文件名写进一个done.json,下次处理前先读取这个文件,跳过已完成项。设计源码时,我会把done.json的读写封装成一个小小的缓存器,避免每次循环都打开文件:

import json class TaskCache: def __init__(self, cache_path): self.path = cache_path self.done = set() if self.path.exists(): self.done = set(json.loads(self.path.read_text(encoding="utf-8"))) def mark(self, key): self.done.add(key) self.path.write_text( json.dumps(list(self.done), ensure_ascii=False, indent=2), encoding="utf-8" ) def is_done(self, key): return key in self.done

TaskCache的核心价值在于崩溃恢复。批量转写十几个视频时,如果进度到 80% 断电,重启后能直接从 80% 继续,而不是从头再来。注意json.dumps里要加ensure_ascii=False,否则文件名如果是中文,会被转成\uXXXX,不影响功能但可读性很差。

模型热切换则解决另一种需求:不同视频时长不同,短音频用 small 足够,长音频或复杂语料用 large 更稳。在源码里设计一个函数,根据文件时长动态选择模型名,然后用getattr或者字典维护模型实例池:

MODEL_POOL = { "short": "base", "medium": "small", "long": "medium", } def pick_model(duration_seconds): if duration_seconds < 300: return MODEL_POOL["short"] elif duration_seconds < 1800: return MODEL_POOL["medium"] else: return MODEL_POOL["long"]

切换模型时,我之前犯过一个错:每次都重新load_model,结果一个批次的视频里反复加载了 20 次模型,白白浪费大量时间。后来改成了实例池,按需加载并缓存,同一个模型只加载一次。这就是“模型热切换”的实际含义,不是真的在推理中途换,而是在批次间按需懒加载。

这三个功能合在一起,你的源码才算真正从“演示脚本”进化成“工具”。我每次把别人的视频转成带字幕的文档时,都会先用 small 跑一个 1 分钟片段,确认语速、背景噪音、说话人数都没问题,再铺到全量数据上。这个习惯救了我很多次,因为 Whisper 对口语的识别表现,有时候真的只能靠经验去试。希望这套设计思路能帮你在自己的机器上少走点弯路,把音频视频转文字这件小事做踏实。

本文还有配套的精品资源,点击获取