Enhance Speech 是针对人声的处理:官方 FAQ 说明它会过滤噪声和杂音。如果录音里混着背景音乐,音乐很可能被一起削弱(这是我根据它的定位做的推断)。 稳妥的做法是:只增强纯人声,再在本地把音乐加回来。这篇做
remix.py:根据测量结果放置音乐音量,人声出现时自动压低音乐(ducking),最后用两遍 loudnorm 达到目标响度。
先看清成品
- 最终混音:−15.9 LUFS(目标 −16)、真峰值 −1.8 dBTP。
- Ducking 生效:音乐在停顿时约 −39 dB,人声出现时压低到约 −54 dB。
- 修复了两个问题:两遍 loudnorm 仍然只有 −16.4(缺少峰值余量);音乐几乎听不见(固定增益和音乐文件本身的音量有关)。pytest 4 项通过。
合规提醒:Adobe Podcast 只能在官方网页中使用(https://podcast.adobe.com),没有公开 API。本文的脚本只在本地处理音频,上传和下载都在网页中手动完成,不自动化操作网页,不共享 Adobe 账号。录制他人(嘉宾、采访对象)之前要取得同意。背景音乐要用你有权使用的素材;示例中的音乐是我用正弦波合成的。本文不列价格,套餐限制以官网为准。
运行说明:box 上没有 Adobe 账号,没有把文件上传到 Enhance Speech。所有脚本和测试都在 box 上真实运行过(ffmpeg 7.1.5、espeak-ng、Python 3.13、numpy 2.5.4)。示例人声是 espeak-ng 合成后加上噪声和回声得到的;B、C 篇中的“增强后”文件是我自己的 ffmpeg 处理链(
standin_enhance.sh),不是 Adobe 的输出。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
remix.py |
混入音乐,并归一到目标响度 |
make_music.sh |
合成测试背景音乐 |
measure.py |
与 B 篇相同 |
test_remix.py |
4 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| ffmpeg / ffprobe | box 上是 7.1.5;Debian/Ubuntu 用 apt install ffmpeg |
| espeak-ng | 只用于生成测试音频 |
| Python | 3.9 以上,pip install numpy pytest |
| 浏览器 | Chrome、Safari、Edge、Firefox 的最新版(官方 FAQ) |
关键事实
| 事实 | 来源 |
|---|---|
| Enhance Speech 会过滤噪声和杂音、调整音高和音量,并做归一化 | FAQ |
| loudnorm 两遍法:第一遍测量,第二遍带 measured_* 参数,并设 linear=true | FFmpeg loudnorm 滤镜文档 |
| sidechaincompress:用第二路输入(人声)控制第一路(音乐)的压缩 | FFmpeg 滤镜文档 |
目标响度 −16 LUFS 和真峰值 −1.5 dBTP 是播客常用的数值,不是 Adobe 的要求;可以用 --target 修改。
文件结构
68-adobe-podcast-C-src/
├── remix.py
├── measure.py
├── make_music.sh
├── make_audio.sh
├── standin_enhance.sh
├── conftest.py
└── test_remix.py
第 1 步:合成背景音乐
#!/usr/bin/env bash
# 生成测试用的背景音乐(我自己合成的 A 大三和弦长音 + 轻微颤音),避免使用任何受版权保护的音乐。
set -euo pipefail
ffmpeg -loglevel error -y -f lavfi -i "sine=f=220:d=26" -f lavfi -i "sine=f=277.18:d=26" -f lavfi -i "sine=f=329.63:d=26" \
-filter_complex "[0][1][2]amix=inputs=3:normalize=0,volume=0.2,tremolo=f=0.5:d=0.3,afade=t=in:d=2,afade=t=out:st=23:d=3,aresample=48000" -ac 1 samples/music_bed.wav
第 2 步:混音脚本
"""把背景音乐加回到增强后的人声里:Enhance Speech 会把音乐当成噪声处理(官方说明它针对人声),
所以应该先增强纯人声,再混入音乐。处理方式:
1. 测量人声和音乐的响度,把音乐放到比人声低 --bed-db LU 的位置(默认 15),用人声做侧链压缩,人声出现时音乐自动再压低(ducking)
2. 两遍 loudnorm:第一遍测量,第二遍按测量值线性归一到目标响度(--target,默认 -16 LUFS,这是常用值,不是 Adobe 的要求)
依赖 ffmpeg。"""
import argparse, json, re, subprocess, sys
from measure import ebur128
# 限幅到约 -4 dBFS(0.63),给第二遍的线性增益留出余量;否则 loudnorm 为了守住真峰值会自动退回动态模式,响度达不到目标
LIMIT = 0.63
def run(args): return subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", *args], capture_output=True, text=True)
def bed_gain(voice, music, bed_db):
"""让音乐的响度比人声低 bed_db LU,和音乐文件本身有多响无关。
(第一版直接 volume=-12dB:我合成的音乐本身只有约 -32 dB,结果停顿时音乐也只剩 -51 dB,几乎听不见。)"""
return round(ebur128(voice)["lufs"] + bed_db - ebur128(music)["lufs"], 2)
def mix_graph(gain_db, extra=""):
return (f"[1:a]volume={gain_db}dB[bed];[0:a]asplit=2[v][key];"
f"[bed][key]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[duck];"
f"[v][duck]amix=inputs=2:duration=first:normalize=0,alimiter=limit={LIMIT}:level=false{extra}")
def remix(voice, music, out, target=-16.0, tp=-1.5, bed_db=-15):
g = bed_gain(voice, music, bed_db)
r = run(["-i", voice, "-i", music, "-filter_complex", mix_graph(g, f",loudnorm=I={target}:TP={tp}:LRA=11:print_format=json"), "-f", "null", "-"])
m = json.loads(r.stderr[r.stderr.rfind("{"):r.stderr.rfind("}") + 1])
second = (f",loudnorm=I={target}:TP={tp}:LRA=11:measured_I={m['input_i']}:measured_TP={m['input_tp']}:"
f"measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}:offset={m['target_offset']}:linear=true")
r2 = run(["-i", voice, "-i", music, "-filter_complex", mix_graph(g, second), "-ar", "48000", out])
if r2.returncode: raise SystemExit(r2.stderr[-800:])
return m
def ducked_music(voice, music, out, bed_db=-15):
"""单独导出被压缩后的音乐轨,用于检查 ducking 是否生效。"""
run(["-i", voice, "-i", music, "-filter_complex",
f"[1:a]volume={bed_gain(voice, music, bed_db)}dB[bed];[bed][0:a]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400", "-ar", "48000", out])
if __name__ == "__main__":
ap = argparse.ArgumentParser(); ap.add_argument("voice"); ap.add_argument("music"); ap.add_argument("out")
ap.add_argument("--target", type=float, default=-16.0); ap.add_argument("--bed-db", type=float, default=-15, help="音乐比人声低多少 LU")
a = ap.parse_args(); m = remix(a.voice, a.music, a.out, a.target, bed_db=a.bed_db)
print("第一遍测量:", {k: m[k] for k in ("input_i", "input_tp", "target_offset")}); print("写入", a.out)
第 3 步:测试
import numpy as np, pytest
import measure as M, remix as R
@pytest.fixture(scope="module")
def out(tmp_path_factory):
d = tmp_path_factory.mktemp("r"); f = str(d / "final.wav"); duck = str(d / "duck.wav")
R.remix("samples/voice_standin.wav", "samples/music_bed.wav", f); R.ducked_music("samples/voice_standin.wav", "samples/music_bed.wav", duck)
return f, duck
def test_loudness_target(out):
# 调试时:只用两遍 loudnorm 时结果是 -16.4(真峰值不够余量,自动退回动态模式);加 alimiter 后为 -15.9
r = M.measure(out[0])
assert abs(r["lufs"] - (-16)) <= 0.5 and r["true_peak"] <= -1.0
def test_ducking(out):
_, vdb = M.frames("samples/voice_standin.wav"); _, mdb = M.frames(out[1]); n = min(len(vdb), len(mdb))
vdb, mdb = vdb[:n], mdb[:n]; speech = vdb > np.percentile(vdb, 60); pause = vdb < np.percentile(vdb, 20)
pause &= mdb > -60 # 排除音乐淡入淡出的静音部分
assert mdb[pause].mean() - mdb[speech].mean() > 3 # 有人声时音乐至少低 3 dB
def test_custom_target(tmp_path):
f = str(tmp_path / "f.wav"); R.remix("samples/voice_standin.wav", "samples/music_bed.wav", f, target=-19)
assert abs(M.measure(f)["lufs"] + 19) <= 0.5
def test_bed_gain_independent_of_music_level(tmp_path):
# 调试时发现:固定 volume=-12dB 时,音乐的最终音量取决于音乐文件本身有多响
import subprocess
loud = str(tmp_path / "loud.wav")
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", "samples/music_bed.wav", "-af", "volume=10dB", loud], check=True)
g1 = R.bed_gain("samples/voice_standin.wav", "samples/music_bed.wav", -15); g2 = R.bed_gain("samples/voice_standin.wav", loud, -15)
assert abs((g1 - g2) - 10) < 0.3
第 4 步:运行
$ bash make_music.sh
$ python remix.py samples/voice_standin.wav samples/music_bed.wav samples/final.wav
第一遍测量: {'input_i': '-18.16', 'input_tp': '-4.01', 'target_offset': '0.04'}
写入 samples/final.wav
$ python -c "import measure as M; r=M.measure('samples/final.wav'); print(r['lufs'], r['true_peak'])"
-15.9 -1.8
$ python -m pytest -q
4 passed
怎么检查结果?
- 最终的 lufs 应该在目标 ±0.5 以内(测试
test_loudness_target)。 - 戴耳机听:说话时音乐明显变小,开头和结尾的音乐能听清楚。
- 释放时间是 400 ms,比句子之间的停顿(0.25~0.45 秒)长,所以短停顿时音乐不会完全回来。想让音乐回得更快,可以减小
release。
调试示例
1. 两遍 loudnorm 仍然没有达到目标。 第一版就用了两遍法,结果是 −16.4 LUFS。第一遍测得 input_i=-17.18、input_tp=-1.49:要达到 −16,需要再提高 1.2 dB,可真峰值已经是 −1.49,提高后会超过 −1.5 dBTP 的上限。这时 loudnorm 会为了守住峰值自动退回动态模式,响度也就达不到目标。
修复:在 loudnorm 之前加一个 alimiter(限幅到约 −4 dBFS),给线性增益留出余量。之后第一遍测得 input_tp=-4.01,最终 −15.9 LUFS / −1.8 dBTP。
2. 测试通过了,但音乐几乎听不见。 第一版用固定的 volume=-12dB 压低音乐,ducking 测试也通过了。但我打印出实际电平后发现,停顿时音乐只有 −51 dB。
原因是我合成的音乐本身只有约 −32 dB(ffmpeg 的 sine 信号源默认振幅是 1/8),再减 12 dB 就几乎听不见了。固定增益意味着结果取决于音乐文件本身的音量。
修复:分别测量人声和音乐的响度,计算增益,让音乐比人声低 --bed-db LU(默认 15)。修复后停顿时音乐约 −39 dB,说话时约 −54 dB。新增的测试确认:把音乐文件放大 10 dB 后,计算出的增益正好少 10 dB。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 响度达不到目标 | 真峰值没有余量 | 保留 alimiter,或把目标调低一些 |
| 音乐太吵或太小 | --bed-db 不合适 |
试试 −12 到 −20 |
| 音乐忽大忽小,像“抽气” | ratio 太高或 release 太短 | 调小 ratio,或调大 release |
| 最终文件比人声长 | 音乐比人声长 | amix 已设置 duration=first,以人声长度为准 |
模板规则
- 只增强人声,音乐在增强之后再混入。
- 音乐音量相对人声设置,不用固定增益。
- 用两遍 loudnorm,并留出峰值余量。
- 背景音乐只用你有权使用的素材。
项目资源
| 用途 | 地址 |
|---|---|
| Enhance Speech 常见问题 | https://helpx.adobe.com/firefly/web/work-with-audio-and-video/work-with-audio/frequently-asked-questions-about-enhance-speech.html |
| Adobe Podcast 功能和套餐对比 | https://podcast.adobe.com/en/features |
| Adobe Podcast 技术要求 | https://helpx.adobe.com/podcast/technical-requirements.html |
| FFmpeg loudnorm 滤镜 | https://ffmpeg.org/ffmpeg-filters.html#loudnorm |
| FFmpeg sidechaincompress 滤镜 | https://ffmpeg.org/ffmpeg-filters.html#sidechaincompress |
人声交给 Enhance Speech,音乐留在本地混:两者都清楚,响度也符合目标。