简介这是一份面向语音技术研究者与交互系统开发者的情绪语音合成ESS资源包对应InterSpeech2021投稿论文 i-ETTS 的配套材料。资源聚焦于交互式情感语音合成综合系统提供用于验证情感表达效果与合成自然度的真实语音样本适合从事语音合成、人机交互或情感计算相关工作的工程师与研究生参考。压缩包共112个文件以95个wav语音样本为主另含项目说明Markdown文档、环境配置YAML文件、Python脚本及展示页面HTML整体仅5.59MB便于快速下载与本地试听。当前已有517人学习使用说明该样本集在情绪语音合成方向具有一定的参考价值。读者可借助语音样本直观感受不同情感状态的合成效果结合项目脚本与配置了解基础实验环境从而为自身研究或系统设计提供对比基准与复现起点。1. 情绪语音合成为什么难落地从一个小项目说起一说到给配音加上情绪很多人第一反应是“多录几遍不就行了”但真正接触过情绪语音合成ESS的人会发现情绪在合成链路里的落地点根本不是音频后期而是文本与声学参数交叉的路口。某次做一个有声书试听A同学人工录了八个小时最后发现调一套参数得到的试听效果反而更稳定、更可复现。i-ETTS就是围绕这个痛点设计的一套以HTML控制台为核心的情绪语音合成系统把“高兴、悲伤、愤怒、惊吓、厌恶”这类离散的情绪标签拆成可调节的连续声学参数让不熟悉底层模型的从业者也能在浏览器里直接试听和调整合成结果适合音频内容策划、配音验证、合成模型评测这几类具体场景中需要快速试听和批量对比的人。2. HTML控制台情绪参数映射与前端骨架怎么搭2.1 为什么用HTML浏览器控制台的三个现实理由情绪合成系统的一大问题是很多现成工具把参数藏在命令行里试听一次要在终端和播放器之间来回切换。i-ETTS选择用HTML做控制台不是拍脑袋决定的而是考虑到三个实际场景。第一团队里最常需要试听的人往往不是模型训练者而是内容策划或音频编辑他们对命令行并不亲近一个能拖滑杆、点按钮的页面才是实际能用的工具。第二HTML页面天然具备跨平台属性不用装特定运行时文件拷到哪台机器都能打开对团队内部协作来说省掉很多环境问题。第三情绪合成的试听过程本质是一个高频迭代任务改一个参数听一遍再改再听。这个过程放在浏览器里配合Web Audio API和流式返回能在音频还没完全生成时就听到首段内容体验上比命令行工具要顺得多。明白了选型理由再看这份资源包里的HTML页面结构就有谱了。2.2 情绪控制面板HTML骨架与交互逻辑控制台的定位是“用鼠标完成情绪参数的调节与试听”所以界面不需要花哨但控件必须覆盖一个情绪合成请求的所有必要维度。下面是一段精简后的核心骨架保留了i-ETTS界面里最关键的四个控件组div classcontrol-panel div classemotion-selector div classemotion-grid button>const EMOTION_PRESETS { neutral: { pad: [0.0, 0.0, 0.0], f0s: 0, speed: 1.0, gain: 0 }, happy: { pad: [0.8, 0.7, 0.3], f0s: 2.5, speed: 1.15, gain: 1.5 }, sad: { pad: [-0.6, -0.4, 0.1], f0s: -2.0, speed: 0.85, gain: -1.5 }, angry: { pad: [-0.4, 0.8, 0.6], f0s: 1.5, speed: 1.2, gain: 2.0 }, fear: { pad: [-0.6, 0.5, -0.3], f0s: 2.0, speed: 1.1, gain: 0.5 }, disgust: { pad: [-0.5, 0.2, -0.2], f0s: -1.0, speed: 0.9, gain: -0.5 } };这份表里有几个细节值得展开。pad数组对应PAD三维情绪空间P是愉悦度A是唤醒度D是支配度。之所以用PAD而不是直接把“高兴”传给后端是因为底层TTS模型的声学控制接口通常只接受连续数值无法直接理解语义标签。PAD是中间语言先把按钮映射成PAD坐标再由后端把PAD坐标换算成超参。这样做的好处是当你不想用预设情绪、想手动微调“悲伤但没那么悲伤”的时候可以直接在PAD坐标上做线性插值而不是回到离散标签的层面拼凑。注意happy和angry的唤醒度都是0.7和0.8这说明它们在“激动程度”上接近但愉悦度一正一负。如果只调A维上去结果一定是既不像高兴也不像愤怒要改变情绪极性必须优先动P维。这是使用这张表时的第一原则先调P再调A最后动D。顺序错了情绪边界就会糊成一团。3. 数据准备与后处理从原始音频到训练清单3.1 原始音频的统一转码采样率、通道与响度基准拿到一份情绪语料最忌讳的就是直接丢给合成管线。不同来源的录音格式五花八门采样率可能从8000Hz到48000Hz都有通道数也有单双声道之分这些不一致会在模型训练时变成无形的噪声。i-ETTS的数据预处理脚本把第一步固定为统一转码这里用ffmpeg做批量处理是标准做法#!/bin/bash # 批量转码统一为22050Hz单声道WAV for f in raw_audio/*.wav; do name$(basename $f) ffmpeg -y -i $f -ac 1 -ar 22050 -b:a 16 -normalize_peak 0.9 \ prepared/${name%.wav}_norm.wav done这段脚本有三个参数值得说明。-ac 1把多声道压成单声道因为绝大多数开源TTS模型在训练和推理时都基于单声道特征提取保留立体声没有收益-ar 22050是常用的情绪合成采样率兼顾了音频清晰度和计算开销-normalize_peak 0.9做的是峰值归一化把全数据集的响度基准拉到同一个水平线上避免某些录音整体偏大或偏小导致模型学到错误的响度对比关系。实际做的时候还有一个容易被忽略的点源文件不一定是.wav格式也可能是mp3、m4a甚至视频音轨。建议先把所有输入统一转成wav再走上面的脚本因为ffmpeg在音频码流转码时如果输入本身有压缩损耗直接二次转码会叠编码误差。多这一步后面对齐标签时能省不少事。3.2 裁剪与静音段处理别让空白吃掉情绪转码只是第一步接下来是裁剪。原始录音往往包含句首句尾的空白段、过长的停顿、甚至翻页声和呼吸声。这些问题如果留在数据里模型会学到把“静音”和“情绪边界”混在一起。这里有一段基于语音活动检测的裁剪脚本import librosa import numpy as np import soundfile as sf def trim_audio_with_margin(path_in, path_out, sr22050, top_db30, margin0.1): # 加载音频 y, sr librosa.load(path_in, srsr, monoTrue) # 基于能量阈值找到非静音边界 y_trimmed, index librosa.effects.trim(y, top_dbtop_db) # 在边界外扩margin秒避免把字首字尾的弱音切掉 margin_len int(margin * sr) start max(0, index[0] - margin_len) end min(len(y), index[1] margin_len) sf.write(path_out, y[start:end], sr) # 打印裁剪前后时长 print(ftrim: {len(y)/sr:.2f}s - {len(y[start:end])/sr:.2f}s)逻辑上librosa的trim函数会逐帧计算RMS能量找到超过top_db阈值的第一帧和最后一帧作为有效语音边界。这里最关键的参数是margin它往有效边界外各扩0.1秒。不做这个外扩直接裸切到边界的话很多汉字的首辅音和尾音会被拦腰截断听感上变成“吃字”。top_db设成30是经验值背景噪声较大的录音建议调到25噪声小的可以放到35要先用一两条数据试听确认阈值是否准确不能一把梭。3.3 生成训练清单标签-音频-参数的对应数据准备好之后需要生成一份训练清单让后续合成流程知道“哪段音频对应哪句文本、属于哪种情绪”。i-ETTS资源里给出的清单格式是TSV制表符分隔每一行四条字段比CSV更省转义麻烦# file id | 文本 | 情绪 | 说话人id session_001_line_001 今天天气还不错 happy spk01 session_001_line_002 我给你打过三次电话 angry spk01 session_002_line_001 这个消息来得太突然 fear spk02三列就够了但如果你之后想在这个数据上做“多说话人情绪合成”建议提前留出speaker_id这一列。因为会有一条铁律情绪表达在不同说话人身上的声学表现差异极大训练时如果只有一两个说话人模型很难在推理阶段泛化到新的音色上。留了这一列后面做起跨说话人迁移时能省掉重新标注的工作。生成这份清单时不要手工敲最好写一个小脚本遍历文件目录从文件命名规则里自动抽取id和情绪标签。文件命名建议用“段编号_情绪_说话人id.wav”这个三段式规则例如“line_017_sad_spk02.wav”脚本里按下划线split就能还原出全部标签。手工整理语料清单是最容易出错的环节一个标签贴错整条训练链路的情绪分类指标都会被带偏。4. 前后端对接合成请求怎么传、流式音频怎么回4.1 接口约定请求体与返回体的字段设计HTML控制台和数据清单都准备好之后最后一块硬骨头是前后端对接。i-ETTS的接口设计采用JSON作为请求格式没有用表单原因在于情绪参数是嵌套结构PAD坐标加文本加基础参数用表单表达多层嵌套会很别扭JSON则天然支持对象嵌套。{ text: 这个消息来得太突然, emotion: { pad: [-0.6, 0.5, -0.3], f0s: 2.0, speed: 1.1, gain: 0.5 }, voice_id: spk02, request_id: req-2025-0906-001 }注意这里有几个字段的用意。pad字段直接传的是数值而不是fear这样的字符串请求方负责把情绪标签翻译成PAD坐标后端只认数值职责分离的好处是后端可以同时服务多种前端。request_id是幂等键因为流式合成的请求可能因为网络抖动被重发后端靠这个id识别出重复请求并复用缓存结果避免一次试听消耗两次GPU推理。返回体用字节流而不是JSON字段常见做法是后端直接返回audio/wav或者audio/mpeg格式的二进制流头部带一个简短元信息。如果一次性返回response header里可以带Content-Length方便前端显示进度如果是流式返回就要用chunked transfer或者WebSocket逐段推送。i-ETTS在长文本场景默认走WebSocket下面展开说明。4.2 前端请求封装WebSocket实时返回与超时试探长文本情绪合成有个天然问题文本越长合成耗时越长如果用HTTP同步请求前端会长时间无响应用户只能干等。i-ETTS前端的处理方式是WebSocket推送合成一段推一段边算边播。function synthWithStream(payload) { const ws new WebSocket(ws://localhost:8482/synth); const player document.getElementById(preview-player); let chunks []; ws.onopen () ws.send(JSON.stringify(payload)); ws.onmessage (evt) { const msg JSON.parse(evt.data); if (msg.type audio_chunk) { chunks.push(msg.base64); // 边收边播收到第一个块就创建播放器 if (!player.src) { const blob new Blob(chunks.map(c Uint8Array.from(atob(c), c c.charCodeAt(0))), { type: audio/wav }); player.src URL.createObjectURL(blob); player.play(); } } else if (msg.type done) { ws.close(); } else if (msg.type error) { document.getElementById(status-line).textContent 合成失败见后端日志; ws.close(); } }; ws.onerror () { document.getElementById(status-line).textContent 连接失败服务未启动; }; }逻辑上看这个封装干了两件事一是把后端推来的base64音频块攒起来二是收到第一个音频块时就立刻拼出blob开始播放后面的块继续往blob数组里推。第6行的判断条件用的是!player.src这是因为i-ETTS的前端播放器是复用的同一个audio元素第二次合成时必须换一个新blob地址才能触发重新加载。这里有个容易被忽略的业务细节onerror只处理了连接层错误如果后端连上了但合成中途模型崩溃前端收到的会是一条error消息而不是断开事件所以onmessage里的error分支也必须有。实际调试时很多人只盯着onerror看结果发现后端日志已经抛异常前端页面却一脸平静这就是两套错误处理没分开导致的。4.3 后端合成服务模型加载、排队与音频返回后端部分i-ETTS用Python的异步Web框架实现了合成服务。核心逻辑可以浓缩成一段示意代码async def synth_ws(websocket): await websocket.accept() req await websocket.receive_json() # 从请求体里拆参数 text req[text] emotion req[emotion] voice req[voice_id] # 加载模型第一次请求时加载后续复用 global tts_model if tts_model is None: tts_model load_tts_model() # 生成中间声学特征转成音频块按段推送 for audio_chunk in tts_model.synthesize_streaming(text, emotion, voice): await websocket.send({ type: audio_chunk, base64: chunk_to_base64(audio_chunk) }) await websocket.send({type: done})逻辑上这是一个标准的“按段推理、按段返回”模式。synthesize_streaming方法内部按标点切分文本每完成一个分句就立刻返回该分句对应的音频块整个句子的语调曲线在分句之间做连续性处理。模型加载用global变量缓存避免每次请求都把几个GB的权重重新加载一遍这在并发场景下是性能关键。参数上有一个边界要说明voice_id这列目前只能原样传给合成器如果要换音色必须保证后端模型在训练阶段见过这个说话人的数据。有些模型支持零样本音色克隆但那是另一套接口情绪合成的复现优先级是先不换音色把情绪一致性调通。异步框架本身要限制最大并发数比如同时最多4个合成任务超出后直接返回错误码而不是让请求排队到用户侧超时。这个问题在下一章展开实践中是翻车高发区。5. 情绪合成避坑指南五条最常见的翻车现场与解决路径5.1 合成结果全是“播音腔”中性数据压过了情绪数据现象选择“高兴”或“愤怒”合成的音频听上去只是语速稍微变化情绪感约等于零。原因这是数据比例失衡。语料里可能有上千句中性语音而带情绪的只有几十句模型在训练时见过太多中性样本默认先往中性拟合情绪标签对生成结果的影响被稀释了。在任何情绪合成任务里中性样本的比例都不要超过50%不同情绪之间也尽量保持数量均衡。解决重新切数据。i-ETTS资源里的预处理脚本会统计各情绪标签的数量如果发现某个情绪的样本数不足平均值的六成就要去补录或做数据增强。临时性的补救办法是把中性样本的语速和基频做随机扰动人为增加多样性但治本方案还是补数据。5.2 批量调用时前端大量超时忘了设置合成任务的并发上限现象单人单条试听没问题一旦脚本发起20个并发请求前端页面开始疯狂转圈过几十秒后全部显示连接失败。原因GPU显存撑不下多路模型实例。每个语音合成模型在显存里占用通常在1GB到3GB之间并发数一多推理速度指数级下降最终触发超时。解决在后端入口加一个信号量限制同时进行推理的任务数。我只允许4路并发其他请求返回一个“忙请重试”的JSON。前端收到这个状态时显示“队列已满请稍候”而不是干等。另外在并发层把长文本和短文本分开排队长文本任务占用GPU时间长短文本插进去蹭个空档能明显提升整体吞吐。5.3 前端缓存了旧音频第二次试听永远听到第一次的结果现象第一次合成“悲伤”试听正常接着改成“高兴”再点合成播放的还是刚才那段悲伤音频。刷新页面后又能正常播放新音频。原因audio元素在src替换时偶尔不触发重新加载特别是传blob URL时浏览器对同一URL会优先使用内存缓存。第二次合成生成的blob和第一次相同如果不重建URL播放器可能直接播旧数据。解决每次合成完成后调用URL.revokeObjectURL释放旧地址再把新blob地址赋给audio.src赋值后强制调用player.load()重新装载。我在代码里加了一个版本号参数拼接在blob URL后面虽然技术上来讲URL会带个查询参数但能彻底斩断缓存。vDate.now()这种土办法绕开缓存很有效。5.4 句尾呼吸音过重能量增益没做完就端到端现象合成出来的句子到最后一个字时音量明显下滑拖出一段呼吸声严重影响情绪表达的自然度。原因语料裁剪时保留了句尾呼吸段模型学到了“句子结尾跟着呼吸声”。加上合成时能量增益参数独立作用处理不了这种时序上的局部异常。解决在数据裁剪阶段用更严格的尾部检测规则检测最后0.5秒的能量是否低于全句能量的30%如果是就再往后切一段。同时在合成后处理阶段读取音频末尾50ms做一个淡出处理衰减量控制在12dB以内。不要在整条音频上做全局降噪那会连情绪表达的呼吸声一起削掉。5.5 一调情绪强度就“用力过猛”缺少平滑过渡曲线现象把愉悦度从0.0直接拖到0.8合成结果像换了一个人说话声调骤变前后句情绪断裂。原因激进地一步把PAD坐标推到目标值模型在参数突变下产生不连续的声学输出正常人的情绪转换是有一个过程的至少持续几百毫秒。解决在请求参数进入合成器之前加一条时域插值曲线把PAD坐标从当前值渐变为目标值过渡时长设为800毫秒。i-ETTS前端已经做了滑杆的连续变化但请求发送时要注意把整个过程压缩成最终值的快照后端拿到的是一次性参数。真正的平滑要由后端的参数平滑模块完成前端的连续变化只是视觉反馈不是声学优化。6. 情绪合成验证与进阶MOS评估和A/B盲测的落地做法验证情绪合成效果的难点在于“主观听感和客观数值对不上”。我常用的验证方法分两条线先用客观指标筛掉明显不合格的样本再对幸存样本做主观试听两条线各自有套路。客观指标上核心看三个维度基频方差F0 variance反映情绪的语调起伏程度语速变化率反映情绪的急切或平缓能量RMS的波动幅度反映情绪的强弱变化。可以写一小段Python脚本直接算import librosa import numpy as np def emotion_prosody_metrics(path, sr22050): y, _ librosa.load(path, srsr) f0, _, _ librosa.pyin(y, fmin60, fmax400, srsr) rms librosa.feature.rms(yy) f0_valid f0[~np.isnan(f0)] return { f0_std: float(np.std(f0_valid)), f0_mean: float(np.mean(f0_valid)), rms_std: float(np.std(rms)), duration: round(len(y) / sr, 2) }计算逻辑上pyin函数提取基频轨迹只取非NaN段的数据因为静音和清音段没有基频值硬算会拉低方差。f0_std这个值非常直观中性语音的基频标准差异常会落在0到25Hz之间而一段“开心”语音通常能到40Hz以上如果两组数据的f0_std没有拉开差距说明情绪的主要声学特征没有起作用。主观评价用标准的MOS打分五级量表分数描述5分情绪表达自然自然、与文本一致4分能明显感知到目标情绪个别字发音不自然3分情绪有体现但不稳定需要听两遍2分情绪模糊听不出差别1分完全没有情绪表达或出现破音杂音我在做A/B盲测时有几个强制习惯控制变量一次只改一个参数两个待测音频叠放到同一个时长基准放音顺序随机化打分人不知道哪条是对照组。如果不遵守“一次只改一个参数”测出来的结果永远归因不到具体原因上。做多了以后我养成了一个习惯任何情绪参数改动都要先跑一遍上面的客观指标脚本把f0_std和rms_std按时间轴画出来确认曲线变化不是只集中在某个瞬间才允许进入主观试听阶段。这段路走通之后你再拿到新语料第一件事不再是“试着训一版听听看”而是先把这些验证脚本准备好心里有尺子再动手会少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?