Python实现慢速英语精听素材处理工作流:字幕清洗、生词提取与Anki制卡
2026/9/14 14:13:21 网站建设 项目流程

学习英语听力一直是很多同学的痛点:材料听了、文本也看了,但最后留在脑子里的内容却很少。其实问题往往不在于“听”得不够多,而在于缺少一套可复用的整理与复习流程。这篇文章不准备讲英语本身,而是带你用 Python 实现一个“慢速英语精听素材处理”工作流。我会用一篇标题为《Mom And Dad Split Home: Can This Family Heal?》的慢速英语内容作为示例,把字幕清洗、句子切分、生词提取、Anki 卡片生成和音频切片串成一套完整工具。读完以后,你拿到任意一篇听力文本或 SRT 字幕,都可以按同一套流程快速生成自己的精听学习包。

慢速英语听力材料有一个特点:播报速度慢、词汇相对基础、句子结构比较完整,非常适合做精听和跟读训练。但原始文本往往带着字幕时间轴、空行、换行和大量重复标记,直接拿来阅读并不舒服;如果想把其中的生词整理成 Anki 单词卡,还需要手动复制粘贴。整个过程既重复又容易出错,而这正是 Python 脚本的价值所在。

我建议读者具备基础的 Python 语法能力,比如函数、文件读写、正则表达式,如果你还没系统学过这些,也能通过本文看懂整体设计,先把脚本体复制运行起来。下面进入正题。

1. 这个案例要解决什么问题

1.1 听力学习中的“素材处理负担”

当我们拿到一篇慢速英语材料时,完整学习链路通常包括:泛听、逐句精听、对照文本、查生词、做跟读、记录复习卡片。麻烦的地方在于,从第二步开始,我们就需要频繁地在文本、词典、Anki 之间来回切换。

比如一段听力原文中出现了句子:

Mom and Dad split home. Can this family heal?

你打算把这句话做成卡片,记录单词splitheal的新含义。手动操作只需要两分钟,但如果你想积累一万句、五千个生词,那么这个工作量的放大效应就很明显了。

更现实的问题是,原文本中通常不只包含干净句子,它可能是这样的 SRT 字幕文件:

1 00:00:00,000 --> 00:00:04,080 Mom and Dad split home. 2 00:00:04,080 --> 00:00:08,126 Can this family heal?

这段内容里,序号和时间轴是给播放器用的,对学习者阅读并不友好。如果能把它们自动清洗掉,再按句子结构重新组织,就会得到一份更清爽的学习文本。

1.2 为什么用 Python 而不是纯手工整理

纯手工整理不是不行,而是很难坚持。每天精听一篇材料,每篇可能有两三百个词,手动去标题、去时间轴、逐句复制到单词软件,很消耗意志力。Python 脚本可以用几秒钟完成重复劳动,让你把精力放到真正重要的“听、说、读、记”上。

下面用一个简单场景说明:

假设你有 10 篇慢速英语文本,每篇 300 个词。手工清洗 10 篇文本大概需要 30 分钟;写脚本的初始成本可能也是 30 分钟,但脚本只要维护一次,后面新增 100 篇都不需要再花额外时间。

这就是把学习工具化的意义。本文用《Mom And Dad Split Home: Can This Family Heal?》这个主题作为处理样例,既能覆盖文本清洗的典型难点,又能演示如何把“句子级学习数据”提取出来。

1.3 本文会实现的最终效果

整套脚本包含四部分能力:第一,读取 SRT 字幕或普通 TXT 文本;第二,清洗时间轴、序号并切分句子;第三,提取生词并生成带上下文的 CSV 学习表;第四,可选地根据字幕时间轴切分音频片段。

最终输出结构是:

output/ ├── clean_text.txt # 清洗后的纯文本 ├── sentences.csv # 句子编号、句子原文、单词数 └── vocabulary.csv # 单词、词频、上下文例句、候选备注字段

有了这些文件,你可以导入自己的单词本,也可以配合 Anki 制卡。

2. 准备工作与运行环境

在开始写代码前,先说明本文使用的环境。我采用 Windows 11 系统 + Python 3.11 演示,但脚本本身只依赖标准库,不强制要求特定操作系统。macOS 和 Linux 同样可以运行,区别只在 Python 安装方式和路径写法。

主要环境清单如下:

依赖项说明
操作系统Windows 11 / macOS / Ubuntu 均可
Python 版本3.9 及以上,3.11 更佳
三方依赖无,全部使用标准库
文本编辑器VS Code 或任意支持 UTF-8 的编辑器
示例素材自建 SRT 字幕文件,用于演示,非原文全文

本文刻意不引入第三方依赖,原因是这类工具脚本需要在不同电脑间复制。如果依赖 pandas、lxml,安装步骤就会变复杂。标准库里的recsvpathlibsubprocess已经足够完成核心功能。

建议先把项目目录建立成下面这样:

fast-english-tools/ ├── data/ │ ├── example.srt │ └── example.txt ├── scripts/ │ ├── preprocess.py │ ├── sentences.py │ └── vocabulary.py ├── output/ └── README.md

在终端中依次创建目录。output目录可以一开始就存在,便于脚本运行时写入,也可以让脚本用mkdir自动创建。下面我们进入目录结构说明。

2.1 为什么需要固定的项目结构

很多人写工具脚本习惯把所有文件放在一个目录里。一旦处理材料多了,目录会变得混乱。固定结构的好处有三个:第一,输入、脚本、输出互不干扰,避免误删;第二,后续接入新素材时,只需放文件到data目录;第三,脚本内部用相对路径定位文件,换机器时也能快速迁移。

为了让代码更健壮,我在脚本开头统一从Path(__file__)推导项目根目录,而不是使用容易出错的绝对路径。

比如在scripts/preprocess.py中这样定位目录:

from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" OUTPUT_DIR = BASE_DIR / "output" if not OUTPUT_DIR.exists(): OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

这段代码会自动找到项目根目录,然后把output目录创建出来。这里用Path(__file__).resolve()是为了避免脚本从别的目录被调用时找不到路径。

2.2 示例素材的准备

由于版权原因,我不会在文中给出完整听力原文。制作示例文件data/example.srt时,可以手动输入下面这样的测试内容:

1 00:00:00,000 --> 00:00:04,080 Mom and Dad split home. 2 00:00:04,080 --> 00:00:08,126 Can this family heal? 3 00:00:08,126 --> 00:00:12,505 They sit together at the kitchen table.

请注意,这只是一个格式样例,用来验证脚本是否能正确去掉序号和时间轴。真实学习时,替换为你自己的合法听力材料即可。

如果你手上只有普通 TXT 文本,那么直接准备一份纯文本就行,脚本也能处理。

2.3 验证 Python 环境

在终端输入:

python --version

如果可以输出版本号,说明 Python 已经安装。如果提示“python 不是内部或外部命令”,在 Windows 上可以尝试:

py --version

确认环境正常后,下面先安装不需要任何依赖,直接进入代码阶段。

3. 模块设计:分步骤理解处理流程

整套程序的本质可以概括为一个流水线:

原始文本 -> 清洗 -> 句子切分 -> 单词统计 -> 学习文件输出

如果还需要切音频,则在清洗阶段保留字幕时间轴,等句子切分后再根据句子的时间范围调用 FFmpeg 切分音频。

我们不需要把它们写成一个巨大的 Python 文件。拆成多个模块,每个模块只负责一个阶段,能显著降低维护成本。下面分别介绍三个脚本的职责:

脚本核心职责输出
preprocess.py清洗 SRT / TXT干净文本
sentences.py切分句子并统计句子 CSV
vocabulary.py提取生词、生成语境例句生词 CSV

下面按次序实现。

4. 实现文本清洗模块

4.1 编写 preprocess.py

先写一个最小但完整的文本清洗脚本。它的任务是读取data目录中的文件,把 SRT 时间轴、序号、空行以及无意义的标签全部清理掉。

这里需要用到正则表达式。SRT 字幕时间轴一般长这样:

00:00:00,000 --> 00:00:04,080

注意时间中的毫秒是用逗号分隔的。VTT 字幕则可能使用点号,例如:

00:00:00.000 --> 00:00:04.080

为了兼容两种格式,匹配时间轴的正则要写成:

TIME_RE = re.compile( r"\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[,.]\d{3}" )

其中[,.]表示逗号或点号都可以,\s*表示时间轴箭头前后可能有空格。

完整代码如下:

# 文件路径:scripts/preprocess.py import re from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" OUTPUT_DIR = BASE_DIR / "output" TIME_RE = re.compile( r"\d{2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[,.]\d{3}" ) def clean_srt_text(text: str) -> str: """清理 SRT/VTT 字幕,只保留字幕文本内容。""" cleaned_lines = [] for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue if line.isdigit(): # 字幕序号,例如 1、2、3 continue if TIME_RE.search(line): # 时间轴 continue if line.startswith("WEBVTT"): continue if line.startswith("Kind:") or line.startswith("Language:"): continue cleaned_lines.append(line) return "\n".join(cleaned_lines) def clean_txt_text(text: str) -> str: """普通文本清洗:去掉多余空行和行尾空格。""" cleaned_lines = [] for raw_line in text.splitlines(): line = raw_line.strip() if line: cleaned_lines.append(line) return "\n".join(cleaned_lines) def read_text_file(file_path: Path) -> str: """以 UTF-8 读取文件。""" return file_path.read_text(encoding="utf-8") def save_text_file(file_path: Path, content: str) -> None: """以 UTF-8 写入文件。""" file_path.write_text(content, encoding="utf-8") def main(): # 尝试读取 SRT,如果文件不存在则读取 TXT srt_path = DATA_DIR / "example.srt" txt_path = DATA_DIR / "example.txt" if srt_path.exists(): raw_content = read_text_file(srt_path) clean_content = clean_srt_text(raw_content) source_name = "example.srt" elif txt_path.exists(): raw_content = read_text_file(txt_path) clean_content = clean_txt_text(raw_content) source_name = "example.txt" else: raise FileNotFoundError("data 目录下没有 example.srt 或 example.txt") # 输出清洗后的文本 out_path = OUTPUT_DIR / "clean_text.txt" save_text_file(out_path, clean_content) print(f"[OK] 已从 {source_name} 清洗文本,输出到 {out_path}") print(clean_content) if __name__ == "__main__": main()

这段脚本最关键的是“过滤”逻辑。有人可能想问:为什么不用一句.replace()把所有-->删除?因为时间轴在一行中可能前后还有文本,如果只删除-->,残留的毫秒时间和数字仍然会混进文本里。按行判断再跳过整行,逻辑更稳定。

4.2 运行清洗脚本

在项目根目录打开终端,运行:

python scripts/preprocess.py

如果data/example.srt中的内容是我上面给的样例文本,那么输出文件output/clean_text.txt内容应为:

Mom and Dad split home. Can this family heal? They sit together at the kitchen table.

这里要注意:真实听力材料有时会出现对话跨行的情况,例如一行只有Can this,下一行才是family heal?。清洗脚本只负责去除格式,不会强行合并断行。合并句子逻辑放在下一个模块中处理。

5. 实现句子切分与统计模块

5.1 句子切分的基本思路

清洗后的文本依然可能是多行结构,我们需要把它转换成“一行一句”的结构。慢速英语文本中句子边界通常有句号、问号或感叹号,但英文缩写也有点号,比如:

Dr. Smith lives in New York.

这里的Dr.不是句子结束。因此简单按.切分会出错。本文作为入门版,采用一个折中方案:先用空行或换行把字幕块分开,如果整段没有明显分隔,再按“标点 + 空格”的规则切分。

更精确的方案是使用 NLTK 或 spaCy,但这会增加环境安装成本。对于慢速英语材料,Dr.Mr.这类缩写出现频率不高,我们可以在一开始把常见缩写替换为占位符,避免破坏句子边界。

下面的示例先处理“按句号、问号、感叹号后跟空格切分”的情况:

# 文件路径:scripts/sentences.py import re import csv from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" OUTPUT_DIR = BASE_DIR / "output" # 常见缩写占位替换,防止被错误切分 COMMON_ABBREVIATIONS = { "Dr.": "Dr@", "Mr.": "Mr@", "Mrs.": "Mrs@", "Ms.": "Ms@", "St.": "St@", } def protect_abbreviations(text: str) -> str: """将常见缩写中的点替换为占位符,句子切分后再还原。""" for abbr, placeholder in COMMON_ABBREVIATIONS.items(): text = text.replace(abbr, placeholder) return text def restore_abbreviations(text: str) -> str: """把占位符还原为缩写。""" for abbr, placeholder in COMMON_ABBREVIATIONS.items(): text = text.replace(placeholder, abbr) return text def split_sentences(text: str) -> list[str]: """ 将文本切分为句子。 这里以 . ! ? 后跟空白作为边界。为了保留缩写, 在真正切分之前先替换常见缩写。 """ text = protect_abbreviations(text) # 合并换行,把多个空白压成一个空格 text = re.sub(r"\s+", " ", text) # 按句子结束标点切分 parts = re.split(r"(?<=[.!?])\s+", text) sentences = [] for part in parts: part = restore_abbreviations(part.strip()) if part: sentences.append(part) return sentences def count_words(sentence: str) -> int: """统计句子中的英文单词数。""" return len(re.findall(r"[A-Za-z']+", sentence)) def load_clean_text() -> str: clean_path = OUTPUT_DIR / "clean_text.txt" if not clean_path.exists(): raise FileNotFoundError("请先运行 preprocess.py, 生成 clean_text.txt") return clean_path.read_text(encoding="utf-8") def save_sentences_csv(sentences: list[str]) -> None: out_path = OUTPUT_DIR / "sentences.csv" with open(out_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["sentence_id", "sentence", "word_count"]) for idx, sentence in enumerate(sentences, start=1): writer.writerow([idx, sentence, count_words(sentence)]) print(f"[OK] 已生成句子统计文件:{out_path}") def main(): text = load_clean_text() sentences = split_sentences(text) save_sentences_csv(sentences) print("切分后的句子:") for idx, sentence in enumerate(sentences, start=1): print(f"{idx}: {sentence}(单词数 {count_words(sentence)})") if __name__ == "__main__": main()

5.2 为什么把句子输出成 CSV

CSV 是跨软件交换数据时使用最广泛的表格格式之一。Excel、Numbers、Python 的csv库都能直接处理。输出 CSV 后,后续不仅可以用脚本提取单词,还可以直接用 Excel 对句子做筛选、统计和人工校对。

csv.writer在写入时需要设置newline="",这是 Python 官方文档推荐的做法,不然在 Windows 上可能出现多一个空行的情况。这是一个很容易被忽略的细节。

运行下面命令:

python scripts/sentences.py

预期输出:

切分后的句子: 1: Mom and Dad split home.(单词数 5) 2: Can this family heal?(单词数 4) 3: They sit together at the kitchen table.(单词数 7)

关于单词数统计,我目前用的是[A-Za-z']+,这个正则会把它当作一个单词。对于it's这类缩写,它会被统计为 1 个单词;如果想拆成itis两个词,需要更复杂的词法处理,暂不展开。

6. 实现生词提取与学习卡片模块

6.1 文本到词频统计的流程

学习听力材料时,生词通常具备两个特征:第一,它不在你的常用词汇表里;第二,它在当前文本中对理解句子产生阻碍。计算机无法真正理解“阻碍”,但我们可以用词频来辅助筛选:全文中反复出现、且不属于停用词的单词,很值得优先掌握。

处理流程如下:

句子切分结果 -> 单词小写化 -> 去标点 -> 过滤停用词 -> 词频统计 -> 生成 CSV

在组织数据时,最有价值的字段不是孤立的单词,而是单词出现的上下文句子。因此最终生成的 CSV 应有两类文件:

  • word_frequency.csv:词频表。
  • vocabulary.csv:生词 + 首次出现位置 + 上下文句子,可直接用于制卡。

下面是scripts/vocabulary.py的实现:

# 文件路径:scripts/vocabulary.py import csv import re from collections import Counter, defaultdict from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent OUTPUT_DIR = BASE_DIR / "output" STOP_WORDS = { "the", "a", "an", "and", "or", "but", "of", "in", "on", "at", "to", "for", "with", "by", "as", "is", "are", "was", "were", "be", "been", "being", "do", "does", "did", "have", "has", "had", "it", "this", "that", "these", "those", "i", "you", "he", "she", "we", "they", "them", "his", "her", "their", "our", "your", "my", "from", "about", "into", "over", "after", "before", "can", "could", "will", "would", "should", "may", "might", "must", "not", "no", "so", "if", "then", "than", "when", "where", "which", "who", "whom", "because", "but", "also", "there", "here", "what", } def tokenize(text: str) -> list[str]: """提取英文单词,保留撇号,其他标点不作为单词内容。""" return re.findall(r"[A-Za-z']+", text.lower()) def read_sentences_csv(): path = OUTPUT_DIR / "sentences.csv" if not path.exists(): raise FileNotFoundError("请先运行 sentences.py") rows = [] with open(path, "r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: rows.append(row) return rows def is_basic_word(word: str) -> bool: """过滤停用词和过短词。""" if word in STOP_WORDS: return True if len(word) <= 2: return True return False def build_vocabulary_data(sentence_rows: list[dict]) -> None: word_counter: Counter[str] = Counter() word_first_sentence: dict[str, str] = {} word_sentence_id: dict[str, str] = {} for row in sentence_rows: sentence = row["sentence"] sentence_id = row["sentence_id"] words = tokenize(sentence) for word in words: if is_basic_word(word): continue word_counter[word] += 1 if word not in word_first_sentence: word_first_sentence[word] = sentence word_sentence_id[word] = sentence_id frequency_path = OUTPUT_DIR / "word_frequency.csv" with open(frequency_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["word", "frequency"]) for word, count in word_counter.most_common(): writer.writerow([word, count]) vocab_path = OUTPUT_DIR / "vocabulary.csv" with open(vocab_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["word", "frequency", "sentence_id", "context_sentence"]) for word, count in word_counter.most_common(): writer.writerow([ word, count, word_sentence_id.get(word, ""), word_first_sentence.get(word, ""), ]) print(f"[OK] 单词统计文件:{frequency_path}") print(f"[OK] 生词学习文件:{vocab_path}") def main(): rows = read_sentences_csv() build_vocabulary_data(rows) print("提取到的非停用词:") vocab_path = OUTPUT_DIR / "vocabulary.csv" with open(vocab_path, "r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for idx, row in enumerate(reader): if idx >= 20: break print(f"{row['word']}: {row['context_sentence']}") if __name__ == "__main__": main()

这段脚本的默认STOP_WORDS并不完整。它只是一个基础集合。真实学习中,你会发现自己的常用词表与别人不同,比如你非常熟悉kitchen,就不需要把它列为生词;但对另一个学习者来说它可能是生词。

所以脚本里没有删除任何词,只是把它筛选出来。你可以在生成 CSV 后,在 Excel 里按自己的实际水平删掉不认识的常用词,或者把不需要的词从表里删除。用户自定义生词表也是一种必要的人工干预。

6.2 运行生词提取模块

在终端运行:

python scripts/vocabulary.py

输出结果中,会看到类似下面的单词列表:

kitchen: They sit together at the kitchen table. family: Can this family heal?

parentsplit等词如果出现在文本中,也会被提取出来。由于这篇示例文本很短,词频表中的词数不会很多。真实新闻材料通常在 300 到 500 词之间,输出会更丰富。

6.3 为什么要保留“上下文例句”而不是只给单词

很多背单词软件的卡片只显示“单词 + 中文释义”。这样做效率不高,因为脱离语境后,用户很难记住单词在不同句子中的真实用法。

把单词放进原句,例如:

单词:split 原句:Mom and Dad split home.

当你在 Anki 中复习时,看到split会先想“它在这句话里是什么意思”,再回想“home 为什么和 split 搭配”。这个过程比孤立记忆更接近语言习得的真实场景。

如果需要扩展中文释义,你可以把vocabulary.csv导入 Excel,手动增加一列meaning,或者在生成卡片后使用词典 API 自动补全。为保持脚本简单,本文不引入在线词典请求。

7. 进阶功能:根据字幕时间轴切分音频

7.1 切分音频的适用场景

在精听训练中,很多人会想把每一句单独截成一小段音频,循环播放。这样就不用来回拖拽播放器进度条。

如果字幕文件里有时间轴,我们就能自动把每一句对应的音频切出来。前提是:你已经拥有该音频文件的合法副本,并且只用于个人学习。不要对未经授权的受版权保护内容进行二次分发。

切分音频使用的是 FFmpeg 命令。它不是 Python 模块,而是一个独立的命令行工具。安装方式因系统而异:

  • Ubuntu:sudo apt update && sudo apt install ffmpeg
  • macOS:brew install ffmpeg
  • Windows:从 FFmpeg 官网下载压缩包并配置 PATH。

安装后,在终端验证:

ffmpeg -version

看到版本信息后,就可以在 Python 中通过subprocess调用。

7.2 解析 SRT 时间轴并生成切片

为了得到每个句子的起始时间和结束时间,需要先把 SRT 文件解析成结构化数据。上一节中的preprocess.py把时间轴丢掉了,所以在音频切片场景下,我们应该直接从原始 SRT 文件读取。

下面给出一个简易解析函数,它把字幕块拆成录音时间区间和文本内容:

# 文件路径:scripts/split_audio.py import re import subprocess from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" AUDIO_DIR = BASE_DIR / "audio" SRT_TIME_LINE_RE = re.compile( r"(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})" ) def srt_time_to_seconds(time_str: str) -> float: """把 00:00:01,500 或 00:00:01.500 转成秒。""" time_str = time_str.replace(",", ".") hours_str, minutes_str, seconds_str = time_str.split(":") hours = int(hours_str) minutes = int(minutes_str) seconds = float(seconds_str) return hours * 3600 + minutes * 60 + seconds def parse_srt_blocks(srt_content: str) -> list[dict]: """解析 SRT,返回带 start/end/text 的字典列表。""" blocks = re.split(r"\n\s*\n", srt_content.strip()) items = [] for block in blocks: lines = block.strip().splitlines() if not lines: continue timestamp_line = None text_lines = [] for line in lines: if "-->" in line: timestamp_line = line elif line.strip().isdigit(): continue else: text_lines.append(line.strip()) if timestamp_line is None or not text_lines: continue match = SRT_TIME_LINE_RE.search(timestamp_line) if not match: continue start_text, end_text = match.groups() start_sec = srt_time_to_seconds(start_text) end_sec = srt_time_to_seconds(end_text) items.append({ "start": start_sec, "end": end_sec, "text": " ".join(text_lines), }) return items def find_audio_file() -> Path: audio_candidates = list(AUDIO_DIR.glob("*.mp3")) + list(AUDIO_DIR.glob("*.m4a")) if not audio_candidates: raise FileNotFoundError("audio 目录中没有 mp3/m4a 文件") return audio_candidates[0] def split_single_audio(audio_path: Path, start: float, end: float, out_path: Path) -> None: cmd = [ "ffmpeg", "-y", "-i", str(audio_path), "-ss", str(start), "-to", str(end), "-c", "copy", str(out_path), ] subprocess.run(cmd, check=True, capture_output=True, text=True)

实际执行时,还需要保证输出文件名不包含特殊字符。建议使用句子编号作为文件名,例如:

def build_output_filename(index: int, sentence: str) -> str: short_sentence = re.sub(r"[^A-Za-z]+", "_", sentence)[:40].strip("_") if not short_sentence: short_sentence = f"sentence_{index}" return f"{index:03d}_{short_sentence}.mp3"

这样生成的文件像001_Mom_and_Dad_split_home_.mp3,直观且不容易冲突。

需要提醒的是,用-c copy可以避免重新编码,速度很快,但它对音频时间点的切割精度取决于音频编码格式,某些场景下可能出现前后几十毫秒的偏差。如果对精度要求高,可以去掉-c copy,改为重新编码:

ffmpeg -i input.mp3 -ss 0.0 -to 4.08 -c libmp3lame output.mp3

但重新编码更慢,也会造成音质损耗。对于日常精听练习,-c copy通常足够用了。

7.3 音频切片与字幕文本结合

为了让后续操作中能同时拿到“音频文件名”和“字幕文本”,最方便的方式是生成一份segments.csv,包含以下字段:

segment_id,start_seconds,end_seconds,audio_file,sentence

很多移动端单词卡工具支持“文本 + 音频”组合。把audio_file字段加入卡片,就可以在例句播放时同步听到原声音频,真正把听力训练和词汇复习结合起来。

这里不继续扩展完整 GUI 或数据库部分。你可以先用少量音频测试切分效果,确认起点和终点没有明显偏移后,再批量处理整篇材料。

8. 用示例材料完整跑一遍

8.1 从 SRT 到学习卡片

假设项目目录已经建立,data/example.srt内容如下:

1 00:00:00,000 --> 00:00:04,080 Mom and Dad split home. 2 00:00:04,080 --> 00:00:08,126 Can this family heal? 3 00:00:08,126 --> 00:00:12,505 They sit together at the kitchen table.

依次执行三行命令:

python scripts/preprocess.py python scripts/sentences.py python scripts/vocabulary.py

第一次执行后,建议把三个脚本的输出都打开检查。clean_text.txt应没有时间轴和序号;sentences.csv里的每一行应是一句完整的话;vocabulary.csv中提取的词表应基本合理。

如果有不该出现的单词进入词表,比如the,说明STOP_WORDS没有覆盖它。你可以在集合里补充,也可以直接忽略,因为高频停用词并不全是生词。

8.2 遇到的问题排查

这里先列几个运行过程中可能出现的问题。

问题现象常见原因解决思路
运行脚本提示找不到文件终端不在项目根目录cd到项目根目录再执行
中文路径报错Windows 控制台编码问题代码中读写文件统一指定encoding="utf-8"
CSV 用 Excel 打开中文乱码CSV 默认用 UTF-8 编码,Excel 需要使用 UTF-8-SIG写入 CSV 时可用encoding="utf-8-sig"
正则匹配不到时间轴字幕格式是 WebVTT,毫秒用点号时间轴正则中已兼容[,.],如果仍失败需检查原始格式
句子单词数统计偏少连字符或撇号处理规则不一致根据需求调整tokenize正则

这里专门说一下中文乱码问题。Python 的open()默认编码在 Windows 上可能是gbk,而脚本中如果用encoding="utf-8"写入,Excel 打开时又可能猜成gbk,于是出现乱码。为了避免这个问题,可以把 CSV 文件的编码从utf-8改成utf-8-sigutf-8-sig会在文件开头加入 BOM 标记,Excel 更容易识别。

如果你使用 WPS 或 macOS 的 Numbers,通常不需要这个设置,但写成utf-8-sig也不影响正常使用。

修改sentences.py中写入 CSV 的那一行即可:

with open(out_path, "w", encoding="utf-8-sig", newline="") as f:

同理,vocabulary.py中写入 CSV 的地方也改成utf-8-sig

9. 数据处理与工程化建议

9.1 不要在原始文件上直接修改

处理听力素材时,最大的风险是破坏原始文件。如果你在一个脚本中同时完成“读取 -> 清洗 -> 覆盖源文件”这三件事,一旦清洗逻辑有 bug,原始文本就没了。即使不怕内容丢失,也需要重新下载,而有些下载来源不一定能保证长期有效。

更好的方式是坚持输入和输出分离。原始文件永远放在data目录,处理结果统一输出到output目录。如果某一步输出结果不理想,重新调整脚本后再次运行即可,不需要担心破坏素材。

9.2 函数职责单一化

从本文看到,每个脚本都很短,每个函数只做一件事:清洗、切分、生成 CSV。这样的代码即使有缺陷,定位也很快。相反,如果写成一个 500 行的单文件,阅读和测试成本都会增加。

在后续扩展时,建议为纯函数添加简单测试。例如split_sentences可以这样验证:

def test_split_sentences(): text = "Mom and Dad split home. Can this family heal?" result = split_sentences(text) assert len(result) == 2 assert result[0] == "Mom and Dad split home." assert result[1] == "Can this family heal?" print("test passed")

如果你没用过pytest,也可以只在if __name__ == "__main__":里调用这种验证函数。无论项目大小,核心逻辑有自动化验证,都是防止重构时引入回归的好习惯。

9.3 版权与合规

慢速英语类材料通常有正式发布的文字稿与音频。自己做学习笔记没有问题,但要注意:

  • 不要批量抓取未经授权的站点内容。
  • 不要把下载的音频和文本重新打包上传到公开平台。
  • 生成的学习卡片不要包含第三方付费课程或未授权复制的讲义。

如果在公司或组织内部分享这类工具,也应该确保输入的素材来源有合法授权。这一点与写代码本身同样重要。

9.4 从“手动查词”到“更自动化的流程”

本文的生词提取只做了“机器初筛”,没有自动获取词性和中文释义。这是因为自动补全释义往往依赖第三方词典 API,每家 API 的字段、限额和授权方式都不同,写死会造成文章失效。

你可以继续扩展的方向有两个。第一个方向是调用本机词典软件:把单词写入剪贴板,或生成一个.txt文件,再配合欧路词典、GoldenDict 的批量查词功能。第二个方向是接入公开词典 API,解析 JSON 返回结果并更新到 CSV 中。实现前记得查看服务条款与配额限制。

无论选择哪个方向,人工校对仍然是最后一道关。机器会把数据给你整理好,但哪些词值得放进复习队列,最终由你的学习目标决定。

10. 常见问题与排查思路

为了减少调试时间,这里集中列一批实践中容易遇到的问题。

10.1 正则切分失败

如果原文中的句子结尾没有空格,例如:

Mom and Dad split home.Can this family heal?

那么(?<=[.!?])\s+这个正则无法切分,因为home.Can之间没有空白。现实中这类格式很少,但不排除复制文本时丢失空格。

解决方案:先做“标点后补空格”的预处理,再切分。例如:

text = re.sub(r"([.!?])(?=[A-Z])", r"\1 ", text)

这个正则会匹配.?後紧跟大写字母的场景,并在中间补一个空格。慢速英语字幕文本里,如果每行是一句完整句子,这个问题就不严重。

10.2 SRT 文件名编码错误

读取 SRT 时,少数文件不是 UTF-8 编码,而是UTF-8 with BOMANSI。直接read_text(encoding="utf-8")可能会抛异常,或者读出的文本首行包含\ufeff字符。

解决办法是使用utf-8-sig读取:

content = Path("example.srt").read_text(encoding="utf-8-sig")

utf-8-sig可以自动去掉开头的 BOM。你可以把read_text_file函数的编码从utf-8改为utf-8-sig,这样兼容性更好。

10.3 脚本输出正常但 Excel 打开 CSV 出现大量逗号错位

原因通常是句子中包含英文逗号,而 CSV 写入时没有正确加引号。Python 的csv.writer会默认处理这种情况,会把包含逗号的字段用引号包裹。如果你是用字符串拼接去写 CSV,则很可能出现问题。

所以统一使用csv.writer,不要自己用write(f"{sentence}, {word}\n")拼接。这个坑非常常见,值得在代码注释中提醒。

10.4 FFmpeg 不在系统 PATH 中

如果subprocess.run(cmd)抛出了FileNotFoundError,说明系统找不到ffmpeg命令。验证方式是在终端单独运行:

ffmpeg -version

如果命令不存在,需要先安装,或在 Python 中传入 FFmpeg 的绝对路径。例如:

FFMPEG_BIN = r"D:\ffmpeg\bin\ffmpeg.exe"

建议不要用绝对路径写死在公共脚本里,因为换一台电脑就会失效。可以把FFMPEG_BIN提成一个配置变量,放在脚本顶部,让使用者自行维护。

10.5 文本行数很多导致程序崩溃

如果你的语料库非常大,比如一次性处理几十万字,利用read_text一次读入仍然可行,但要注意 Python 的内存占用通常不会很大,几十万字文本只有不到 10 MB,普通电脑可以承受。

如果是处理上 GB 的音频切片,那就不要把所有音频一次性读入内存,而是逐句调用 FFmpeg 输出。本文提供的方法就是逐句执行,占用内存很少。

11. 最佳实践与后续扩展

11.1 给脚本增加配置开关

当前脚本把输入文件名直接写死在代码中。最佳实践是把“输入材料”和“处理逻辑”解耦。例如新增一个config.py

SRT_FILE = "data/example.srt" AUDIO_FILE = "audio/example.mp3" OUTPUT_ENCODING = "utf-8-sig" CLEAN_OUTPUT = "output/clean_text.txt"

这样当你要换一篇材料时,不需要阅读整个脚本找硬编码路径,只改配置文件即可。为了降低本文复杂度,我没有把配置单独拆出来,但在实际项目中强烈推荐。

11.2 结合间隔重复制卡

vocabulary.csv可以直接导入 Anki。Anki 是开源的间隔重复记忆软件,导入前需要先设置模板字段。

模板可以设计成正面和反面两个字段。正面显示地道的原声句子或单词,反面显示翻译或例句播放按钮。在 Anki 中新建笔记类型时,字段大致设置为:

Word Context Meaning Audio

然后从 Anki 的“文件 -> 导入”中选择vocabulary.csv。注意修改分隔符选项,默认是 Tab 还是逗号取决于你的文件。按本文生成的是逗号分隔 CSV,导入时需要指定对应符号。

如果你不想让单词出现重复,可以在导入前按字段word做去重,或者直接沿用生成文件里的词频字段,把频率大于 1 的单词优先处理。

11.3 加入词形归并之后再统计词频

目前程序把healheals当成两个单词,把familyfamilies也当成两个单词。从背单词角度看,它们属于同一个词族,一次性掌握更高效。

要实现这一点,可以引入词形还原工具,例如 NLTK 的WordNetLemmatizerspaCy。安装 NLTK 后,可以这样处理:

from nltk.stem import WordNetLemmatizer lemmatizer = WordNetLemmatizer() print(lemmatizer.lemmatize("families")) # family print(lemmatizer.lemmatize("heals")) # heal

但 NLTK 需要额外下载语料数据,会让脚本从“零依赖”变成“五步安装”。文中的基础版足够日常使用,如果你已经熟悉 Python 包管理,再自行扩展词形归并也不迟。

11.4 输出“精听句子包”供移动端使用

移动端背单词工具通常支持通过 CSV 或 Excel 导入自定义卡片。你可以把本文生成的sentences.csv中每个句子作为一条听力例句卡:左声道保留完整句子,必要时可切分句首和句尾。

对慢速英语学习者来说,最好的操作路径其实是“听一句 -> 暂停 -> 复述 -> 对照文本 -> 复习生词”。整个过程需要音频和文本严格对应。本文的segments.csv方案已经为你准备好了数据基础,下一步就是选择一个顺手的前端工具。

11.5 建议的使用节奏

如果你每天精听一篇材料,不建议每天把整套流程都跑一遍。更好的方案是每天跑preprocess.pysentences.py,生成当天文本;每周日集中跑vocabulary.py,把一周的生词统一导出到 Anki。这样既能避免被工具制作占用过多时间,又能保证复习材料及时更新。

当前这套脚本非常适合个人电脑上的小规模使用。如果你计划运营一个英语学习社群,并要把工具分享给更多人使用,建议加上 Web 界面并通过表单上传音频和字幕文件。不过这样一来就涉及服务器、用户数据上传和内容合规等更复杂的问题,超出了本文范围。

整套代码的核心价值在于:让重复劳动自动化,而不是让学习本身被自动化。慢速英语材料依然需要你认真听、反复读,脚本只是帮你把素材整理成更适合高效学习的形态。你可以把示例代码复制下来,用自己手头的合法听力材料试验一次,然后根据习惯调整停用词表、输出编码或文件目录结构,最终形成属于你自己的精听工作流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询