设计实践

Suno 音乐来源鉴别:验证 C2PA 内容凭据

Suno 从官网下载的歌曲会带上 C2PA 内容凭据(Content Credentials),标明这是用 Suno 生成的 AI 内容。官方提供了一个免费、不需要 Key 的检测接口。 这篇写一个批量检查脚本:可以检查一个目录里的文件,也可以检查公开链接。上传前先在本地用 ffprobe 确认文件是音频、大小不超过 100MB;请求串行发送,每次间隔 2

BINBHO · 图文教程

Suno 从官网下载的歌曲会带上 C2PA 内容凭据(Content Credentials),标明这是用 Suno 生成的 AI 内容。官方提供了一个免费、不需要 Key 的检测接口。 这篇写一个批量检查脚本:可以检查一个目录里的文件,也可以检查公开链接。上传前先在本地用 ffprobe 确认文件是音频、大小不超过 100MB;请求串行发送,每次间隔 2 秒,遇到 429 按 Retry-After 等待。结果写进 c2pa_report.csv。

先看清成品(box 上真实调用了 Suno 官方接口)

  • 我用 ffmpeg 自己合成的测试音 → no_suno_provenance。
  • 文本文件 → 本地就拦下了,没有上传。
  • 无法下载的链接 → fetch_failed。
  • http 或内网地址 → url_not_allowed。
  • pytest 2 项通过。

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

运行说明:box 上没有 Suno 生成的歌,所以 verified_suno 这个结果没有真实测到,只在单元测试里用假数据覆盖了。其他几种结果都来自真实请求。

做完之后你会得到什么?

成果 说明
suno_c2pa.py 批量检测本地文件和公开链接,写出 CSV
test_c2pa.py 测试 429 重试、非音频文件不上传
samples/ 自己合成的 my_tone.mp3,以及一个 notes.txt

需要准备哪些环境?

Python 3.9 以上,pip install requests pytest,并安装 ffmpeg(用到其中的 ffprobe)。不需要 Key。如果要大批量调用,可以设置 CONTACT 环境变量,把联系方式写进 User-Agent。

接口要点(来自 Suno 官方页面)

  • 上传文件:POST https://studio-api.prod.suno.com/api/c2pa/detect,multipart 字段名是 file。
  • 公开链接:POST …/api/c2pa/detect/url,请求体 {"url": "https://…"},链接必须直接指向媒体文件。
  • 返回:JSON 里的 verdict 取值为 verified_suno、no_suno_provenance 或 inconclusive,同时附带凭据详情。
  • 限制:文件上限 100MB,接口有限流,官方的说法是“请合理使用”。
  • 适用范围:只对新下载的歌有效,之前下载的旧文件不带凭据。另外,检测结果只返回给调用者本人,不会公开。

文件结构

51-suno-A-src/
├── suno_c2pa.py
├── test_c2pa.py
└── samples/
    ├── my_tone.mp3
    └── notes.txt

第 1 步:代码

"""用 Suno 官方免费的 C2PA 检测接口,批量检查音频是否带 Suno 内容凭据。
接口说明见 https://suno.com/suno-credentials :上传文件 POST /api/c2pa/detect(字段 file),
或者用公开链接 POST /api/c2pa/detect/url。verdict 取值:verified_suno / no_suno_provenance / inconclusive。
文件上限 100MB,接口有限流:本脚本串行请求、每次间隔 2 秒、遇到 429 按 Retry-After 等待。"""
import csv, json, os, pathlib, subprocess, sys, time
import requests

API = os.environ.get("SUNO_C2PA_API") or "https://studio-api.prod.suno.com/api/c2pa"
UA = "binbho-tutorial-suno-c2pa/1.0" + (f" ({os.environ['CONTACT']})" if os.environ.get("CONTACT") else "")
MAX = 100 * 1024 * 1024
GAP = float(os.environ.get("C2PA_GAP", "2"))


def is_audio(path):
    """接口本身不校验文件类型(实测上传 txt 也返回 no_suno_provenance),所以先用 ffprobe 本地确认。"""
    r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name",
                        "-of", "csv=p=0", str(path)], capture_output=True, text=True)
    return r.returncode == 0 and r.stdout.strip() != ""


def _post(s, url, **kw):
    for _ in range(3):
        r = s.post(url, timeout=120, **kw)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "10"))); continue
        return r
    return r


def check_file(s, path):
    p = pathlib.Path(path)
    if p.stat().st_size > MAX:
        return {"verdict": "local_skip", "detail": "超过 100MB"}
    if not is_audio(p):
        return {"verdict": "local_skip", "detail": "不是音频文件(ffprobe 未发现音轨)"}
    with open(p, "rb") as f:
        r = _post(s, f"{API}/detect", files={"file": (p.name, f)})
    return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"verdict": "error", "detail": r.text[:120]}


def check_url(s, url):
    r = _post(s, f"{API}/detect/url", json={"url": url})
    return r.json()


def main(targets, out="c2pa_report.csv"):
    s = requests.Session(); s.headers["User-Agent"] = UA
    items = []
    for t in targets:
        if t.startswith("https://") or t.startswith("http://"):
            items.append(t)
        elif pathlib.Path(t).is_dir():
            items += sorted(str(p) for p in pathlib.Path(t).iterdir() if p.is_file())
        else:
            items.append(t)
    rows = []
    for i, t in enumerate(items):
        d = check_url(s, t) if "://" in t else check_file(s, t)
        verdict = d.get("verdict") or d.get("error_type", "error")
        rows.append([t, verdict, d.get("manifest_present", ""), d.get("detail", "")])
        print(f"{verdict:<20} {t}  {d.get('detail', '')}")
        if i < len(items) - 1 and not str(verdict).startswith("local"):
            time.sleep(GAP)
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f); w.writerow(["target", "verdict", "manifest_present", "detail"]); w.writerows(rows)
    print("报告:", out)
    return rows


if __name__ == "__main__":
    main(sys.argv[1:])

第 2 步:测试

import json, subprocess, pathlib
import suno_c2pa as m


class FakeResp:
    def __init__(self, code, body, headers=None):
        self.status_code, self._b = code, body
        self.headers = {"content-type": "application/json", **(headers or {})}
        self.text = json.dumps(body)
    def json(self): return self._b


class FakeSession:
    def __init__(self, seq): self.seq, self.calls, self.headers = list(seq), [], {}
    def post(self, url, **kw): self.calls.append(url); return self.seq.pop(0)


def test_retry_on_429(monkeypatch, tmp_path):
    monkeypatch.setattr(m.time, "sleep", lambda s: None)
    s = FakeSession([FakeResp(429, {}, {"Retry-After": "1"}), FakeResp(200, {"verdict": "verified_suno", "manifest_present": True})])
    a = tmp_path / "a.mp3"
    subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", "sine=d=0.5", str(a)], check=True)
    assert m.check_file(s, a)["verdict"] == "verified_suno" and len(s.calls) == 2


def test_non_audio_never_uploaded(tmp_path):
    t = tmp_path / "x.txt"; t.write_text("hi")
    s = FakeSession([])
    assert m.check_file(s, t)["verdict"] == "local_skip" and s.calls == []

第 3 步:真实运行

$ python suno_c2pa.py samples https://example.com/not-audio.mp3 "http://localhost/a.mp3"
no_suno_provenance   samples/my_tone.mp3  No Suno content credentials found.
local_skip           samples/notes.txt  不是音频文件(ffprobe 未发现音轨)
fetch_failed         https://example.com/not-audio.mp3  Could not fetch audio from that URL.
url_not_allowed      http://localhost/a.mp3  That URL cannot be used. Provide a public https URL.
报告: c2pa_report.csv

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

URL 接口出错时返回的是 HTTP 400,内容是真实的原样记录:

{"status_code": 400, "detail": "Could not fetch audio from that URL.", "error_type": "fetch_failed", "retryable": false, …}
{"status_code": 400, "detail": "That URL cannot be used. Provide a public https URL.", "error_type": "url_not_allowed", "retryable": false, …}

调试示例

上传文本文件也返回了“没有 Suno 凭据”。 我先用 curl 把一个写着 hello 的 txt 文件传给 /detect,接口返回的是 HTTP 200 和 {"verdict": "no_suno_provenance", …},并没有报“这不是音频”。如果直接拿这个结果当结论,就会把一份不是音频的文件,记成一首“确认不是 Suno 生成的歌”。所以脚本在上传前先用 ffprobe 检查有没有音轨,不是音频就标为 local_skip,这样也少占用一次限流额度。

常见问题

现象 原因 处理
Suno 下载的歌检测结果是 no_suno_provenance 文件是凭据功能上线之前下载的,或者经过了重新编码或元数据清理 重新从 Suno 下载原文件再检测
inconclusive 凭据不完整或已损坏 用原始下载文件重新检测
url_not_allowed 不是公开的 https 链接 换成直接指向媒体文件的公开 https 链接
429 请求太频繁 加大 C2PA_GAP,按 Retry-After 等待

模板规则

  • 只用官方的检测接口,不用非官方的生成封装。
  • 上传前先在本地校验文件类型和大小,请求串行并控制频率。
  • 检测原始下载文件,不检测后期处理过的成品。

项目资源

用途 地址
Suno 内容凭据与检测接口说明 https://suno.com/suno-credentials
Suno 帮助:Suno 是否拥有我的音乐 https://help.suno.com/en/articles/2416769
Suno 帮助:免费计划的权利 https://help.suno.com/en/articles/9601601
Suno 帮助:付费订阅的权利 https://help.suno.com/en/articles/9601665

检测结果会交给 C 篇的发布检查清单使用:上架之前,先确认手上这个文件是 Suno 的原始下载。