news 2026/9/11 20:19:19

中文语音识别全流程:Fbank特征、声学模型与语言模型解码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文语音识别全流程:Fbank特征、声学模型与语言模型解码

简介:基于深度学习的Python中文语音识别系统设计源码,面向语音识别初学者、算法研究人员及毕业设计开发者,覆盖了从音频预处理、特征提取、声学建模、语言模型到解码输出的完整技术链路。资源包共包含87个文件,整体约133.82MB,其中30个Python源文件承担模型构建与训练逻辑,30个文本文件用于配置与说明,22个lst列表文件管理训练、测试数据集划分,另有pkl词典、tsv标注文件等辅助构建中文发音词典与标注体系。目前已有114人学习浏览。源码内设数据预处理、模型训练、模型评估和解码等模块,提供GRU/CNN声学模型、CBHG语言模型等可运行实现,并配有语料处理脚本与词典生成脚本,便于理解中文语音识别系统模块间的协作关系。对于希望快速搭建中文语音识别原型或进行算法对比的实验者,这份资源能够省去大量环境搭建和数据清洗时间,直接作为二次开发的基础工程。

1. 中文语音识别不是单个模型就能跑通的

打开这份源码包,第一眼容易集中在gru_ctc_am.pycnn_ctc_am.py上,但真正让它能跑起来的,是feature_extract.pydata_load.pyhyperparams.py这些容易被忽略的文件。中文语音识别和英文不同,声学模型通常输出拼音或声韵母单元,而不是直接输出汉字。这意味着训练完声学模型之后,还要经过语言模型解码才能得到可读的中文文本。这套源码恰好把这条链路补齐了,从 THCHS-30、AISHELL 等语料读入,到 Fbank 特征提取,再到 CNN/GRU/FSMN 和 CTC 训练,最后用语言模型纠错。如果你正打算做一个 Python 项目来理解深度学习的完整落地流程,或者需要一份可改动的系统设计模板,这份源码比单模型教程有价值得多。

2. 特征提取与数据管道:从中文WAV到Fbank特征

2.1 语料库选择:为什么是四个而不是一个

源码里出现 primewords、st-cmds、thchs30、aishell 四个数据目录,这不是随意堆砌。THCHS-30 是清华大学录制的短句普通话,标注较干净,适合做主训练集;AISHELL 来自开源语音团队,语速偏口语化,适合做跨场景验证;Primewords 是手机录音,环境噪声大,用来测模型的鲁棒性;ST-CMDS 偏向命令词,适合单独做语音命令任务。我一般会把它们合并成一个经过统一预处理的元列表,而不是一个数据集训练到底。

四个语料库在项目中的典型定位如下:

语料目录录音环境特点在项目中的用法
thchs30安静室内句子规整,音素标注齐全主训练集,验证集来源
aishell相对干净普通话语速自然,文本覆盖广扩充训练集,测试模型泛化
primewords手机录音噪声、混响明显样本增强,模拟真实使用
st-cmds半封闭环境短命令词,口语化命令词识别测试

注意,四个语料的文本格式并不统一,有的带声调标注,有的用数字调。源码里的read_data_prime.pyread_aishell.pygen_thchs_lable.py就是在做这件事:把各自的 label 统一转换成模型可用的拼音序列。这个转换直接决定后期的训练文本有多干净,我强烈建议先单独运行这些脚本,检查dict.txt的覆盖范围,再继续下一步。统一成拼音而不是汉字,是因为汉字类别有几千个,直接预测文本会让模型收敛极慢;而拼音单元加上声调大约只有几百个,在准确率和复杂度之间更容易取得平衡。

2.2 手动实现Fbank特征提取

语音信号经过预加重、分帧、加窗、傅里叶变换、Mel滤波器组,得到的就是Fbank,通常也被称为FilterBank。与MFCC相比,Fbank保留更多原始频谱信息,在深度学习中表现通常更好。项目里的feature_extract.py核心逻辑可以简化成下面的代码:

import numpy as np import librosa def extract_fbank(wav_path, sample_rate=16000, win_len=25, win_step=10, num_filter=40): y, sr = librosa.load(wav_path, sr=sample_rate) # 预加重:抵消发声时低频能量被口腔衰减,提升高频 pre_emphasis = 0.97 y = np.append(y[0], y[1:] - pre_emphasis * y[:-1]) win_length = int(sample_rate * win_len / 1000) hop_length = int(sample_rate * win_step / 1000) # power=2.0表示用能量谱,n_mels是Mel频带数量 fbank = librosa.feature.melspectrogram( y=y, sr=sr, n_fft=win_length, hop_length=hop_length, n_mels=num_filter, power=2.0) log_fbank = librosa.amplitude_to_db(fbank, ref=np.max) # 全局均值方差归一化,避免不同录音音量差异干扰训练 log_fbank = (log_fbank - np.mean(log_fbank)) / np.std(log_fbank) return log_fbank.T

这段代码有两个关键参数需要根据你的数据调整。sample_rate必须和录音文件一致,四个语料库都是16kHz,所以这里写死是安全的;num_filter用40,和Kaldi默认配置一致,你的GPU显存够大也可以改成64,特征维度越高,模型容量需求越大。

win_len=25表示每帧25毫秒,win_step=10表示帧移10毫秒。这是语音识别中主流配置:25毫秒能覆盖一个元音的大部分稳态段,10毫秒步长让相邻帧有重叠,保证时序连续性。如果你用流式模型,帧移往往要增大到20~30毫秒来降低计算延迟;离线识别保持这个配置即可。

关于预加重系数0.97:这是电话语音处理流传下来的经验值,对16kHz语音依然有效。如果你的数据是8kHz电话语音,应该保留;如果是麦克风阵列远场录音,可以适当减小到0.95。归一化使用全局均值和方差,而不是帧内,是因为语音不同帧能量差异很大,帧内归一化会把静音帧和语音帧无差别放大,反而破坏动态范围。

2.3 数据列表与批处理读取

项目根目录下的train.wav.lstdatalist和多个read_*.py脚本,本质上是在做一个事情:把音频路径和文本路径组织成训练用的列表。列表文件的每一行一般由wav路径文本内容用制表符分隔。我习惯的做法是:

def load_list(lst_path): with open(lst_path, "r", encoding="utf-8") as f: lines = [] for line in f: line = line.strip() if not line: continue wav_path, text = line.split("\t") lines.append((wav_path, text)) return lines

加载列表后,配合特征提取函数生成批数据。需要特别注意,CTC语音识别中wav经过特征提取后的帧数远大于字符数,所以要在训练时用tf.keras.utils.Sequence或自定义数据集来动态填充batch。不要把整个特征矩阵一次性放进内存,四个语料库总共几十小时音频,内存很容易被占满。调用GetData.py时,我通常会限制单次读取样本数,并记录当前batch内的真实帧数,便于CTC loss计算时传入序列长度。

列表文件通常还会包含第三列说话人ID或数据集来源,方便做分层采样。源码里datalist目录下的文件就承担这个角色。做验证集切分时,要按说话人或录音文件切分,而不是按句子随机切分。否则同一说话人的不同句子同时出现在训练集和验证集,CER会被低估,这个细节很容易踩坑。

3. 声学模型选型:从FSMN到CNN+GRU+CTC

3.1 为什么是CTC而不是注意力

声学模型要解决的是输入语音帧序列和输出文本序列长度不一致的问题。注意力机制可以直接做seq2seq,但计算量较大,且并行度低,训练数据量不足时容易过拟合。CTC则通过在输出序列中插入blank符,让模型可以重复或跳过输出,从而在时序上对齐。中文拼音序列一般不超过几十个字符,而语音帧可能有几百上千,CTC天然适合这种场景。

CTC还有一个额外优势:不需要音频和文本的帧级对齐标注。THCHS-30虽然有音素级别标注,但其他几个语料只有句子级文本,CTC可以只用句子级文本训练。源码中同时出现了gru_ctc_am.pycnn_ctc_am.py,说明作者在同一份系统中尝试了两种声学模型。GRU模型长于时序建模,CNN模型长于局部频谱模式捕捉。在实际工程中,我建议把两者串起来:先用CNN下采样频谱维度,再用GRU聚合上下文,最后接全连接层输出概率。

3.2 FSMN单元:流式识别的备选方案

FSMN的全称是Feedforward Sequential Memory Networks,它是Mobvoi提出的一种前馈序列记忆网络。和LSTM、GRU不同,FSMN没有循环结构,只通过显式记忆模块缓存前几帧特征,因此前向计算延迟更低,更适合流式语音识别。FSMNCell.py的核心思想是把当前帧和相邻帧拼在一起做变换。

下面是我按相同思路实现的一个简化FSMN单元:

class FSMNCell(tf.keras.layers.Layer): def __init__(self, filter_size=128, memory_size=5): super().__init__() self.filter_size = filter_size self.memory_size = memory_size self.fc = tf.keras.layers.Dense(filter_size, use_bias=False) def call(self, x): # x: (batch, time, feature) hidden = self.fc(x) # 对每个时刻取前memory_size帧的隐藏向量拼接 time_len = tf.shape(hidden)[1] memory = tf.concat( [hidden[:, i:i + self.memory_size, :] for i in range(time_len)], axis=-1 ) # 摊平后映射回filter_size output = tf.keras.layers.Dense(self.filter_size)(tf.reshape(memory, [tf.shape(x)[0], -1])) return output

这里为了演示把记忆构建简化成一串循环,实际项目里应该用tf.keras.layers.Conv1D或滑动窗口实现,避免循环构造带来的性能问题。memory_size控制上下文窗口长度。一般设置成5时,提取约50毫秒的历史上下文,适合短时语音;如果模型想知道更远的历史信息,可以增加到7或9,但设备端内存消耗会明显上升。

FSMN在实际部署中和CNN混合使用效果更好。CNN负责压缩特征中的噪声,FSMN负责记忆时序上下文,两者都不需要递归计算,在CPU上推理速度很快。如果你的场景是智能音箱、门禁语音,建议保留FSMNCell.py附近的实验代码;如果只做离线转写,优先使用GRU。

3.3 CNN+GRU混合声学模型实现

cnn_with_fbank.pygru_ctc_am.py的区别主要在主干网络。我给出的实现同时利用两种网络结构:

import tensorflow as tf def build_cnn_gru_ctc(input_dim=40, num_outputs=1200): inputs = tf.keras.Input(shape=(None, input_dim)) # 增加通道维度,方便Conv2D处理 x = tf.expand_dims(inputs, axis=-1) # 两个Conv2D块提取局部频谱模式 x = tf.keras.layers.Conv2D(32, (3, 3), padding="same", activation="relu")(x) x = tf.keras.layers.MaxPooling2D((1, 2))(x) # 只压缩频率维 x = tf.keras.layers.Conv2D(64, (3, 3), padding="same", activation="relu")(x) x = tf.keras.layers.MaxPooling2D((1, 2))(x) # 转回序列格式 shape = tf.shape(x) x = tf.reshape(x, (shape[0], shape[1], shape[2] * shape[3])) # 双向GRU建模时间依赖 x = tf.keras.layers.Bidirectional(tf.keras.layers.GRU(256, return_sequences=True))(x) x = tf.keras.layers.Dropout(0.2)(x) # 每个时刻输出拼音概率分布 logits = tf.keras.layers.Dense(num_outputs, activation="softmax")(x) model = tf.keras.Model(inputs, logits) return model

模型里MaxPooling2D只压缩频率维度,不压缩时间维度,这样可以保留较高的时间分辨率,避免CTC因帧数不足而无法对齐。如果你做实时识别,建议把双向GRU改成单向GRU,并且使用因果卷积避免未来信息泄漏。双向GRU在中文识别中作用很大,因为前后语义都会影响拼音判断,比如“西安”的“西”和“安”之间停顿时长不同,双向结构能捕捉这种边界。代价是解码延迟高,不能用于在线流式识别。

num_outputs设置为1200的原因和字典大小直接相关:声韵母数量加上汉字、blank、start/end等特殊标记,通常不会超过1500。这个值不能拍脑袋,必须由gen_dict.py生成的拼音字典决定。如果字典有1500个单元,而你设置了1200,训练时会直接报错。

3.4 超参数配置与训练循环

hyperparams.py是整一套系统的总控文件,训练前应先检查以下参数:

参数名建议值作用
sample_rate16000与录音采样率一致
num_filters40Fbank滤波器个数
batch_size32每批训练样本数
max_epochs100最大训练轮次
learning_rate0.001Adam初始学习率
num_units256GRU隐层维度
num_outputs1200声学单元字典大小
dropout_rate0.2防止过拟合

训练循环的骨架在train.py中实现。核心逻辑是:读取特征和标签,计算CTC loss,梯度更新,保存checkpoint。下面是一段基于TensorFlow 2的参考实现:

def train(model, train_loader, optimizer, ctc_fn, max_epochs): for epoch in range(max_epochs): for batch_wav, batch_labels, input_len, label_len in train_loader: with tf.GradientTape() as tape: logits = model(batch_wav) # (batch, time, num_outputs) loss = ctc_fn(batch_labels, logits, input_len, label_len) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) print(f"epoch {epoch} loss: {loss.numpy():.4f}")

注意ctc_fn需要传入batch中每条样本的输入帧长度和标签长度。很多初学者在这里出错,以为CTC会自动推断长度,实际上TensorFlow的tf.nn.ctc_loss要求明确指定labelslogitslabel_lengthlogit_length。其中label_length是拼音序列的字符数,logit_length是卷积池化后的时间帧数。如果你在MaxPooling2D中使用的时间步长不为1,别忘记重新计算logit_length,否则训练loss会一直停在NaN附近。

还有一个容易混淆的概念:blank和空格。CTC中的blank是模型学习出来的“占位符”,用于分隔连续的重复字符;空格是最终输出文本中的一个真实汉字间分隔符。如果把空格也作为字典中的一个普通单元,解码时需要额外过滤,否则Beam Search会出现大量重复和消失。源码中如果看到lable.txtdict.txt含有<blank><space>两种标记,就是这个目的。

4. 语言模型与解码:把拼音序列变成汉字

4.1 为什么必须额外接语言模型

声学模型输出的是拼音概率序列,即使识别准确,一个拼音也可能对应几十个汉字。以 “zhong guo” 为例,可以有“中国”“忠国”“中果”等候选。语言模型通过统计中文文本中词序列的概率,把这几个候选重新排序,让“中国”排第一。这也是language_model/CBHG_lm.py存在的意义:它就是用来给声学模型候选重新打分的模块。

语言模型有统计n-gram和神经网络两种。CBHG属于神经网络形式,训练成本比声学模型低得多,即使只有几MB的中文文本语料,也能训练出可用的模型。如果不想自己训练,也可以用开源的中文拼音到汉字映射表,但在源码里既然已经有CBHG_lm.py,直接用它成本更低。

CBHG并不是为语音识别发明的,它最早出现在Tacotron语音合成模型中,由一维卷积Bank、Highway网络和双向GRU组成。它的优势在于用多组不同宽度的卷积核捕捉n-gram特征,再用Highway网络加深非线性,最后用GRU输出序列级预测。我把它用在解码阶段时,输入是拼音序列,输出是每个位置的汉字分布,这样语言模型就能和声学模型无缝结合。

4.2 用CBHG实现拼音到汉字映射

下面是一个面向语言模型任务的简化CBHG模块:

class CBHGBlock(tf.keras.layers.Layer): def __init__(self, hidden_size=128): super().__init__() # 1到6的卷积核宽度,相当于n-gram特征 self.conv_bank = [ tf.keras.layers.Conv1D(hidden_size, k, padding="same", activation="relu") for k in range(1, 7) ] self.highway = tf.keras.layers.Dense(hidden_size) self.gru = tf.keras.layers.Bidirectional(tf.keras.layers.GRU(hidden_size, return_sequences=True)) def call(self, x): # 拼接不同卷积核的输出 conv_list = [conv(x) for conv in self.conv_bank] conv_out = tf.concat(conv_list, axis=-1) highway_out = tf.nn.relu(self.highway(conv_out)) output = self.gru(highway_out) return output

这段实现里conv_bank是关键。卷积核宽度从1到6,宽度越窄,关注相邻音节的局部关系;宽度越宽,关注更远的上下文。例如k=1能判断单字概率,k=4能捕捉类似“中华人民共和国”这种常见搭配。实际训练中,语言模型和声学模型的字典必须一致,否则在解码阶段会出现拼音序列中有些字符在LM字典里缺失,导致整个beam搜索崩掉。训练CBHG时,输入是build_corpus.py生成的拼音序列,输出是汉字序列,损失函数用交叉熵即可。

语言模型训练完成后,保存为独立的tensorflow模型或numpy矩阵,方便在解码时快速查表。不要把它和声学模型放在同一个checkpoint里训练,一般分开训练损失更稳定。项目中的language_model目录下除了CBHG_lm.py,还有model_layers.pyvocab.pkl,后者就是保存的字典和统计参数。

4.3 解码策略:贪心搜索和Beam Search

解码时将声学模型的输出按帧取 argmax,去掉重复和blank,得到拼音序列,这是贪心搜索。它速度最快,但无法纠正单帧错误。Beam Search会在每一步保留概率最高的若干个候选路径,结合语言模型重新打分,最后选择总分最高的序列。工程中我通常用beam_size=20。

两种策略的对比:

策略速度准确率适用场景
贪心搜索极快实时命令词识别
Beam Search离线长句转写
Beam + 语言模型最慢最高中文听写,字幕生成

eval.py中常见一个循环,对每个测试音频依次调用模型和语言模型。下面是Beam Search的简化实现:

def beam_search_decode(logits, lm_model, beam_size=5): logprobs = np.log(logits + 1e-7) T = logits.shape[0] seqs = [([], 0.0)] # (路径, 累计对数概率) for t in range(T): candidates = [] for seq, score in seqs: top_k = np.argsort(logprobs[t])[-beam_size:] for token in top_k: new_seq = seq + [token] lm_prob = lm_model.predict_line(new_seq) new_score = score + logprobs[t][token] + 0.1 * np.log(lm_prob) candidates.append((new_seq, new_score)) seqs = sorted(candidates, key=lambda x: -x[1])[:beam_size] return seqs[0][0]

注意这里的0.1是语言模型权重,在不同数据集上差别很大。THCHS-30上语料干净,权重取0.1~0.3效果就不错;如果你的数据噪声大,权重调高反而会掩盖声学模型的判断。建议在验证集上按0.1间隔扫描,选CER最低的权重。

如果计算资源比较紧张,可以先只用声学模型做贪心解码,再用语言模型对整条候选序列做重打分(rescoring),而不是在beam每一步都融合语言模型。这样beam search只需跑一次,速度快很多,准确率下降幅度通常在1%以内。

5. 评估指标CER和复现时的调优技巧

5.1 用CER而不是accuracy评估识别效果

中文语音识别的最终结果是一段文本,评估时不能直接比较两个字符串是否完全相同,因为少一个标点或者错一个多音字都是一个错误。语音识别社区通常用字符错误率(CER)来衡量,计算公式是编辑距离除以参考文本字符数。源码里的eval.py至少会输出CER,我们也可以自己实现一个简化版本:

def compute_cer(reference, hypothesis): # 去掉空格,以字符为单位计算编辑距离 ref = list(reference.replace(" ", "")) hyp = list(hypothesis.replace(" ", "")) len_ref, len_hyp = len(ref), len(hyp) dp = np.zeros((len_ref + 1, len_hyp + 1)) for i in range(len_ref + 1): dp[i][0] = i for j in range(len_hyp + 1): dp[0][j] = j for i in range(1, len_ref + 1): for j in range(1, len_hyp + 1): cost = 0 if ref[i - 1] == hyp[j - 1] else 1 dp[i][j] = min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + cost) return dp[len_ref][len_hyp] / len_ref

这个动态规划表格填完就是编辑距离,最后再除以参考长度。项目里如果报告accuracy,通常指的是“准确率=1-CER”,不要把两者直接画在同一张图上对比。日志中如果CER从0.8降到0.4,说明模型开始学到内容;如果在0.5附近抖动,先检查特征和标签是否对齐。

5.2 复现时最容易翻车的三个环节

第一,Python和TensorFlow版本。这份源码里有多个模型文件,API风格可能混有TensorFlow 1.x的写法。如果你直接用TensorFlow 2.10跑1.x代码,tf.contrib这类模块会直接报错。我一般用虚拟环境固定版本,先读hyperparams.py看有没有tf.contrib引用,有的话就手动替换成TensorFlow 2的对应函数,比如用tf.keras.layers代替tf.contrib.rnn

第二,CTC的标签长度计算。尤其在使用卷积池化后,logit_length往往不等于原始帧数。简单的方法是在模型里加一个shape分支,把tf.shape(logits)[1]动态输出到损失函数,而不是自己推算。下面的对照表可以帮助定位问题:

现象可能原因检查位置
loss停在-90附近missing blank,CTC窗口为空检查字典是否包含blank
识别结果全是最后一个字输入长度和标签长度传反打印logit_length和label_length
中文乱码读取label时编码不一致统一使用utf-8
显存溢出batch_size过大调小或使用梯度累积

第三,中文文本编码。label.txtdict.txt是UTF-8编码,但Windows下用默认GBK打开会乱码。写数据读取脚本时统一指定encoding="utf-8",否则训练到一半会莫名崩溃。read_data_prime.py这类转换脚本,建议先打印前五条记录确认文本正常,再进入训练流程。

如果你只是想做一套快速验证,我建议先取 THCHS-30 中的50条句子跑10轮,确认loss和CER都有下降趋势,再扩展到全量数据。这样能在十分钟内暴露配置错误,避免全量训练两天后才发现代码问题。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 20:17:34

AOSP级云手机虚拟化底座:真机克隆与Play Integrity绕过

简介&#xff1a;本资源是一个面向Android系统开发工程师、云游戏平台架构师及安全合规研究人员的AOSP级云手机与云游戏开发平台&#xff0c;聚焦ARM/X86跨架构虚拟化、真机参数仿真、风控绕过与Play Integrity认证适配等核心难题&#xff0c;助力开发者在合规前提下构建高仿真…

作者头像 李华
网站建设 2026/9/11 20:16:52

Swin-Transformer融合YOLOv7的电力杆塔检测方案

简介&#xff1a;本资源是一套基于Swin-Transformer改进YOLOv7的电力杆塔目标检测系统&#xff0c;面向人工智能、自动化、电子信息等专业的学生、教师及工程技术人员&#xff0c;解决输电线路巡检中杆塔小目标识别精度低、遮挡鲁棒性差等实际问题。压缩包共20个文件&#xff0…

作者头像 李华
网站建设 2026/9/11 20:16:01

有源噪声控制中的卡尔曼滤波:动态噪声实时估计与抵消

简介&#xff1a;本资源面向电子信息工程、计算机及数学专业本科生&#xff0c;提供一套基于卡尔曼滤波的有源噪声控制&#xff08;ANC&#xff09;系统完整实现方案&#xff0c;用于课程设计、期末大作业或毕业设计中动态噪声衰减问题的建模与仿真。压缩包共13个文件&#xff…

作者头像 李华
网站建设 2026/9/11 20:15:12

智能日志告警平台:Kafka+ELK+Ollama+OpenClaw架构实践

日志平台我这些年搭过不少&#xff0c;但真正把大模型塞进告警链路&#xff0c;是最近这一年让我觉得最有意思的事。以前做日志收集&#xff0c;基本就是 Kafka 做缓冲、ELK 做存储检索、Kibana 画几个 dashboard&#xff0c;告警全靠正则和阈值&#xff0c;误报多、漏报也多。…

作者头像 李华
网站建设 2026/9/11 20:14:58

深耕人居品质,2026 年瓷砖十大品牌精选汇总

伴随人居理念变化&#xff0c;家装瓷砖除墙地面铺装功能外&#xff0c;在环保、设计、物理性能、空间配套等方向调整。鹰牌陶瓷鹰牌陶瓷创立于 1974 年&#xff0c;企业位于佛山陶瓷产业带&#xff0c;生产瓷砖、岩板、墙板、石晶地板等品类。冠珠瓷砖冠珠瓷砖产品品类较多&…

作者头像 李华
网站建设 2026/9/11 20:14:47

2026国产编码器TOP8:选型替换与故障排查实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华