设计实践

Suno 音乐后期:响度标准化与标签写入

从 Suno 下载的歌,响度往往各不相同。这篇用 ffmpeg 的 loudnorm 做两遍处理(EBU R128 标准):第一遍测量,第二遍按测量值做线性增益,统一到 -14 LUFS、真峰值 -1 dBTP,再写入标题、艺人和“AI-generated with Suno”标签。原文件不动。 遇到动态范围很大的歌,脚本会自动调高目标 LRA,确保只做整体

BINBHO · 图文教程

从 Suno 下载的歌,响度往往各不相同。这篇用 ffmpeg 的 loudnorm 做两遍处理(EBU R128 标准):第一遍测量,第二遍按测量值做线性增益,统一到 -14 LUFS、真峰值 -1 dBTP,再写入标题、艺人和“AI-generated with Suno”标签。原文件不动。 遇到动态范围很大的歌,脚本会自动调高目标 LRA,确保只做整体增益,不压缩歌曲的起伏。

先看清成品(box 上真实运行,素材是自己合成的演示音频)

  • 处理前:-10.18 LUFS,真峰值 -1.18 dBTP,LRA 17.90。
  • 处理后:-14.00 LUFS,真峰值 -5.00 dBTP,LRA 17.90(动态保持不变)。
  • 标签写入成功。
  • pytest 2 项通过。

合规提醒:截至本文写作时(2026-10),Suno 没有公开的、可自助开通的生成 API。网上流传的“Suno API”封装大多是通过模拟登录或抓取 cookie 调用内部接口,违反服务条款,还可能导致封号,本文一律不用。歌曲请在 Suno 官方网页或 App 里生成,本文只处理两件事:一是调用 Suno 官方公开的免费 C2PA 检测接口,二是对你下载的文件做本地后期处理。权利以 Suno 帮助中心为准:免费计划生成的歌只能个人非商用;在 Pro 或 Premier 订阅期间创作的歌可以商用。歌词和参考素材不得侵犯他人版权,也不要模仿真实歌手的声音冒充本人。在中国境内发布,要遵守《生成式人工智能服务管理暂行办法》,并按《人工智能生成合成内容标识办法》标注“AI 生成”。本文不列订阅价格。

注意:重新编码会生成一个新文件,原文件中的 C2PA 凭据很可能不会被带过去(本文没有用真实的 Suno 文件验证过这一点)。所以 C2PA 检测要对原始下载文件做(见 A 篇),原文件也要和成品一起存档。

做完之后你会得到什么?

成果 说明
master.py 两遍 loudnorm 加写入标签;拒绝覆盖原文件
make_demo.sh 生成一段 20 秒、前轻后响的演示音频
test_master.py 测试响度达标、写入标签、宽动态时保持线性

需要准备哪些环境?

ffmpeg(需要带 libmp3lame)、Python 3.9 以上、pip install pytest。

-14 LUFS 是很多流媒体平台常用的参考值;广播电视用的 EBU R128 推荐 -23 LUFS。各平台的标准并不相同,请查看你要发布的平台的说明,再用 --lufs 调整。

文件结构

51-suno-B-src/
├── master.py
├── make_demo.sh
├── demo_track.mp3      # make_demo.sh 生成,测试要用
└── test_master.py

第 1 步:代码

"""发布前的本地后期:ffmpeg 两遍 loudnorm(EBU R128)把响度统一到目标值,
写入标题、艺人和“AI 生成”说明标签,原始文件不动。
注意:重新编码会生成一个新文件,原文件里的 C2PA 内容凭据大概率不会被带过去(本文未验证)。
所以 C2PA 检测要对 Suno 原始下载文件做(见 A 篇),原件也要保留。"""
import argparse, json, re, subprocess, pathlib, sys


def run(cmd):
    return subprocess.run(cmd, capture_output=True, text=True, check=True)


def measure(path, I=-14, TP=-1, LRA=11):
    r = run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-af",
             f"loudnorm=I={I}:TP={TP}:LRA={LRA}:print_format=json", "-f", "null", "-"])
    j = re.findall(r"\{[^{}]*\"input_i\"[^{}]*\}", r.stderr, re.S)[-1]   # JSON 打在 stderr 末尾
    return json.loads(j)


def master(src, dst, I=-14, TP=-1, LRA=11, title="", artist="", ai_note="AI-generated with Suno"):
    m = measure(src, I, TP, LRA)
    if float(m["input_lra"]) > LRA:
        # ffmpeg 文档:目标 LRA 低于源 LRA 时,linear 会退回 dynamic(动态压缩,改变歌曲的起伏)。
        # 音乐通常想保留动态,所以把目标 LRA 抬到源 LRA,保证只做整体增益。
        print(f"提示:源 LRA {m['input_lra']} > 目标 {LRA},目标 LRA 调整为 {float(m['input_lra']):.1f},以保持线性增益")
        LRA = round(float(m["input_lra"]) + 0.1, 1)
    af = (f"loudnorm=I={I}:TP={TP}:LRA={LRA}: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")
    run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), "-af", af, "-ar", "48000",
         "-metadata", f"title={title}", "-metadata", f"artist={artist}",
         "-metadata", f"comment={ai_note}", "-metadata", "genre=AI Generated",
         "-c:a", "libmp3lame", "-b:a", "320k", str(dst)])
    after = measure(dst, I, TP, LRA)
    return m, after


def tags(path):
    r = run(["ffprobe", "-v", "error", "-show_entries", "format_tags", "-of", "json", str(path)])
    return json.loads(r.stdout)["format"].get("tags", {})


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("src"); ap.add_argument("dst")
    ap.add_argument("--lufs", type=float, default=-14); ap.add_argument("--tp", type=float, default=-1)
    ap.add_argument("--lra", type=float, default=11)
    ap.add_argument("--title", default=""); ap.add_argument("--artist", default="")
    a = ap.parse_args()
    if pathlib.Path(a.src).resolve() == pathlib.Path(a.dst).resolve():
        sys.exit("输出不能覆盖原始文件")
    before, after = master(a.src, a.dst, a.lufs, a.tp, a.lra, title=a.title, artist=a.artist)
    print(f"处理前:{before['input_i']} LUFS,真峰值 {before['input_tp']} dBTP,LRA {before['input_lra']}")
    print(f"处理后:{after['input_i']} LUFS,真峰值 {after['input_tp']} dBTP,LRA {after['input_lra']}")
    print("标签:", json.dumps(tags(a.dst), ensure_ascii=False))
#!/bin/sh
# 生成一段 20 秒的演示音频:前 10 秒很轻,后 10 秒很响(代替你从 Suno 下载的歌曲)
ffmpeg -y -loglevel error -f lavfi -i "aevalsrc='0.05*sin(2*PI*220*t)+0.04*sin(2*PI*330*t)':d=10:s=44100" \
  -f lavfi -i "aevalsrc='0.6*sin(2*PI*262*t)*(0.6+0.4*sin(2*PI*2*t))+0.3*sin(2*PI*392*t)':d=10:s=44100" \
  -filter_complex "[0][1]concat=n=2:v=0:a=1" -c:a libmp3lame -b:a 192k demo_track.mp3

第 2 步:测试

import subprocess, master


def test_master_hits_target(tmp_path):
    src = tmp_path / "s.mp3"
    subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", "sine=f=440:d=8", "-af", "volume=0.1", str(src)], check=True)
    before, after = master.master(src, tmp_path / "o.mp3", title="t", artist="a")
    assert abs(float(after["input_i"]) - (-14)) < 1.0
    assert float(after["input_tp"]) <= -0.5
    tg = master.tags(tmp_path / "o.mp3")
    assert tg["comment"] == "AI-generated with Suno" and tg["title"] == "t"


def test_wide_dynamics_stays_linear():
    # demo_track.mp3:前轻后响,实测源 LRA 17.9 > 默认目标 11
    import pathlib
    out = pathlib.Path("/tmp/_m_test.mp3")
    before, after = master.master("demo_track.mp3", out)
    assert abs(float(after["input_i"]) + 14) < 0.3
    assert abs(float(after["input_lra"]) - float(before["input_lra"])) < 0.5   # 动态没被压缩

第 3 步:运行

$ ./make_demo.sh
$ python master.py demo_track.mp3 demo_master.mp3 --title "秋日夜市(演示)" --artist "BHo BIN"
提示:源 LRA 17.90 > 目标 11,目标 LRA 调整为 17.9,以保持线性增益
处理前:-10.18 LUFS,真峰值 -1.18 dBTP,LRA 17.90
处理后:-14.00 LUFS,真峰值 -5.00 dBTP,LRA 17.90
标签: {"genre": "AI Generated", "title": "秋日夜市(演示)", "artist": "BHo BIN", "comment": "AI-generated with Suno", "encoder": "Lavf61.7.103"}

$ python master.py demo_track.mp3 demo_track.mp3
输出不能覆盖原始文件

$ python -m pytest -q
2 passed in 1.29s

调试示例

明明写了 linear=true,结果却是 -13.80 LUFS,LRA 也从 17.9 变成了 19.5。 第一版直接用了默认目标 LRA=11,跑出来的结果是:处理后 -13.80 LUFS,真峰值 -3.19,LRA 19.50。响度没有正好落在 -14,歌曲的动态也被改变了。

ffmpeg 文档里 linear 选项的说明是这样的:“Target LRA shouldn’t be lower than source LRA … If any of these conditions aren’t met, normalization mode will revert to dynamic.” 也就是说,目标 LRA 低于源 LRA 时,linear 不会生效,ffmpeg 会悄悄退回动态模式。

这首演示音频的源 LRA 是 17.9,比 11 大,所以触发了回退。把目标 LRA 调到不低于源 LRA 之后,结果正好是 -14.00 LUFS,LRA 保持 17.90 不变。现在代码会自动做这个调整,并打印提示。

常见问题

现象 原因 处理
响度没有正好落在目标值 loudnorm 退回了动态模式 目标 LRA 不能低于源 LRA,代码已自动处理
处理后声音被压扁了 用了单遍动态模式 改用两遍处理(本文的做法)
发布平台把音量调小了 成品响度超过了平台的参考值 按平台的说明设置 --lufs
中文标签显示乱码 播放器不支持 ID3v2.4 换一个播放器,或在发布平台上手动填写信息

模板规则

  • 原文件只读,成品另存为新文件。
  • 响度处理用两遍法,处理前后都要测量。
  • 每个成品都写入 AI 生成标识。

项目资源

用途 地址
Suno 内容凭据与检测接口说明 https://suno.com/suno-credentials
ffmpeg loudnorm 文档 https://ffmpeg.org/ffmpeg-filters.html#loudnorm
EBU R128 标准 https://tech.ebu.ch/publications/r128

响度统一、标签写好之后,再交给 C 篇做发布前的最后检查。