效率工具

Adobe Podcast:长录音自动切分与上传前检测

一小时的录音直接拖进 Enhance Speech 的免费版会被拒绝(单个文件上限 30 分钟),视频文件也只有 Premium 能处理。 这篇做 preflightsplit.py:上传前检查格式、大小、时长和每日额度;太长的文件在说话的停顿处切开,不切断句子。

BINBHO · 图文教程

一小时的录音直接拖进 Enhance Speech 的免费版会被拒绝(单个文件上限 30 分钟),视频文件也只有 Premium 能处理。 这篇做 preflight_split.py:上传前检查格式、大小、时长和每日额度;太长的文件在说话的停顿处切开,不切断句子。

先看清成品

  • 31 分钟的 mp3:免费版提示超过上限,Premium 通过;mp4 在免费版提示不支持视频。
  • 20.8 秒的含噪录音按 8 秒上限切成 3 段,切点 7.07 秒和 14.99 秒都落在停顿上。
  • 修复:固定 −35 dB 的静音阈值在有噪声的录音中一个停顿也找不到,改为根据底噪自动计算。pytest 5 项通过。

合规提醒: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 的输出。

做完之后你会得到什么?

成果 说明
preflight_split.py 检查文件;加 --split 时在停顿处切分
make_audio.sh 生成测试录音
test_preflight.py、conftest.py 5 项测试,缺少音频时自动生成

需要准备哪些环境?

项目 说明
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)

关键事实

事实 来源
音频格式 .mp3 .wav .m4a .flac .ogg .aac;视频 .mp4 .mov .m4v(视频和批量上传只限 Premium) Enhance Speech FAQ
免费版:单个文件最长 30 分钟、最大 500 MB,每天最多 1 小时 FAQ、功能页
Premium:单个文件最大 1 GB,每天最多 4 小时;功能页写的单个文件最长时长是 2 小时 FAQ、功能页
下载的格式和上传的一样,不做转换 FAQ、技术要求
Enhance Speech 会过滤噪声和杂音、调整音高和音量,并做归一化 FAQ

官方页面之间有一处不一致:FAQ 中 Premium 单个文件的时长没有写明,功能页写的是 2 小时。脚本按功能页的 2 小时处理。

文件结构

68-adobe-podcast-A-src/
├── preflight_split.py
├── make_audio.sh
├── conftest.py
└── test_preflight.py

第 1 步:生成测试音频(我自己合成的)

#!/usr/bin/env bash
# 生成测试用的“糟糕录音”(我自己合成的):espeak-ng 朗读 → 加粉红噪声、50 Hz 嗡声、回声。
set -euo pipefail
mkdir -p samples
espeak-ng -v en-us -s 150 -w samples/voice_clean.wav \
  "Welcome back to the show. Today we are talking about home recording. First, pick a quiet room. Second, keep the microphone close. Third, record a few seconds of silence so you can hear the noise floor. That is all for today. Thanks for listening."
ffmpeg -loglevel error -y -i samples/voice_clean.wav \
  -f lavfi -i "anoisesrc=color=pink:amplitude=0.04:sample_rate=22050" \
  -f lavfi -i "sine=frequency=50:sample_rate=22050" \
  -filter_complex "[0:a]aecho=0.8:0.6:60|120:0.35|0.2,apad=pad_dur=2[v];[2:a]volume=0.03[h];[v][1:a][h]amix=inputs=3:duration=first:normalize=0,aresample=48000" \
  -ac 1 -c:a pcm_s16le samples/voice_noisy.wav
ffprobe -v error -show_entries format=duration,size -of compact samples/voice_noisy.wav

第 2 步:检查和切分脚本

"""上传到 Adobe Podcast Enhance Speech 之前,检查格式、大小、时长和每日额度;太长的文件在静音处切开。
限制来自官方 FAQ 和功能页(以官网为准):
- 音频格式 .mp3 .wav .m4a .flac .ogg .aac;视频 .mp4 .mov .m4v 只限 Premium
- 免费版:单个文件 ≤ 30 分钟、≤ 500 MB,每天最多 1 小时
- Premium:单个文件 ≤ 2 小时(功能页)、≤ 1 GB,每天最多 4 小时
- 下载的格式和上传的一样(不会转换)
依赖 ffmpeg / ffprobe。"""
import argparse, json, os, re, subprocess, sys

AUDIO = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac"}; VIDEO = {".mp4", ".mov", ".m4v"}
PLANS = {"free": {"max_sec": 30 * 60, "max_bytes": 500 * 1024**2, "daily_sec": 3600, "video": False},
         "premium": {"max_sec": 2 * 3600, "max_bytes": 1024**3, "daily_sec": 4 * 3600, "video": True}}

def duration(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path], capture_output=True, text=True)
    if out.returncode: raise SystemExit(f"ffprobe 无法读取 {path}:{out.stderr.strip()}")
    return float(out.stdout)

def check(path, plan):
    p = PLANS[plan]; ext = os.path.splitext(path)[1].lower(); errs = []
    if ext not in AUDIO | VIDEO: errs.append(f"不支持的格式 {ext}")
    elif ext in VIDEO and not p["video"]: errs.append("免费版不支持视频,只能上传音频")
    size = os.path.getsize(path); dur = duration(path) if not errs else 0
    if size > p["max_bytes"]: errs.append(f"{size / 1024**2:.0f} MB 超过上限 {p['max_bytes'] // 1024**2} MB")
    if dur > p["max_sec"]: errs.append(f"{dur / 60:.1f} 分钟超过单个文件上限 {p['max_sec'] // 60} 分钟(可以用 --split 切开)")
    return {"file": path, "ext": ext, "bytes": size, "seconds": round(dur, 1), "errors": errs}

def noise_floor(path):
    """把音频解码成 16 kHz 单声道,按 50 ms 分帧计算 RMS,取第 10 百分位作为底噪(dBFS)。"""
    import numpy as np
    raw = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", path, "-ac", "1", "-ar", "16000", "-f", "s16le", "-"], capture_output=True, check=True).stdout
    x = np.frombuffer(raw, np.int16).astype(float) / 32768; n = 800
    fr = x[: len(x) // n * n].reshape(-1, n)
    return float(np.percentile(20 * np.log10(np.sqrt((fr ** 2).mean(1)) + 1e-9), 10))

def silences(path, noise_db=None, min_dur=0.25):
    if noise_db is None:                        # 自动阈值:底噪 + 12 dB(固定 -35 dB 在有噪声的录音上找不到任何停顿)
        noise_db = max(min(noise_floor(path) + 12, -20), -60)
    err = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", path, "-af", f"silencedetect=noise={noise_db}dB:d={min_dur}", "-f", "null", "-"],
                         capture_output=True, text=True).stderr
    starts = [float(x) for x in re.findall(r"silence_start: ([\d.]+)", err)]
    ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", err)]
    return [(s + e) / 2 for s, e in zip(starts, ends)]          # 每段静音的中点

def cut_points(total, mids, max_sec):
    """贪心:在不超过 max_sec 的前提下,选尽量靠后的静音点;找不到静音就硬切。"""
    cuts, start = [], 0.0
    while total - start > max_sec:
        cand = [m for m in mids if start < m <= start + max_sec]
        c = cand[-1] if cand else start + max_sec
        cuts.append(c); start = c
    return cuts

def split(path, max_sec, outdir, noise_db=None):
    total = duration(path); cuts = cut_points(total, silences(path, noise_db), max_sec)
    os.makedirs(outdir, exist_ok=True); base, ext = os.path.splitext(os.path.basename(path)); parts = []
    for i, (a, b) in enumerate(zip([0.0] + cuts, cuts + [total]), 1):
        out = os.path.join(outdir, f"{base}_part{i:02}{ext}")
        subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", path, "-ss", f"{a:.3f}", "-to", f"{b:.3f}", "-c", "copy", out], check=True)
        parts.append({"file": out, "start": round(a, 2), "end": round(b, 2), "seconds": round(duration(out), 2)})
    return parts

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("files", nargs="+"); ap.add_argument("--plan", choices=PLANS, default="free")
    ap.add_argument("--split", action="store_true"); ap.add_argument("--max-sec", type=float, help="切分长度,默认用套餐上限")
    ap.add_argument("--out", default="parts"); a = ap.parse_args(argv)
    p = PLANS[a.plan]; rows = [check(f, a.plan) for f in a.files]; bad = 0
    for r in rows:
        print(json.dumps(r, ensure_ascii=False))
        too_long = any("超过单个文件上限" in e for e in r["errors"])
        if a.split and (too_long or a.max_sec):
            for part in split(r["file"], a.max_sec or p["max_sec"] - 5, a.out): print("   →", part)
        elif r["errors"]: bad += 1
    total = sum(r["seconds"] for r in rows)
    print(f"合计 {total / 60:.1f} 分钟;{a.plan} 每日额度 {p['daily_sec'] // 60} 分钟" + (",超出了,需要分几天处理" if total > p["daily_sec"] else ""))
    return 1 if bad else 0

if __name__ == "__main__":
    sys.exit(main())

第 3 步:测试

import os, subprocess, pytest
import preflight_split as P

def gen(path, sec, extra=()):
    subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", f"anullsrc=r=8000:cl=mono", "-t", str(sec), *extra, path], check=True)

@pytest.fixture(scope="module")
def long_mp3(tmp_path_factory):
    p = str(tmp_path_factory.mktemp("a") / "long.mp3"); gen(p, 31 * 60, ["-b:a", "8k"]); return p

def test_free_limit(long_mp3):
    assert any("超过单个文件上限 30 分钟" in e for e in P.check(long_mp3, "free")["errors"])
    assert P.check(long_mp3, "premium")["errors"] == []

def test_video_and_format(tmp_path):
    v = str(tmp_path / "v.mp4"); gen(v, 1)
    assert any("不支持视频" in e for e in P.check(v, "free")["errors"]) and P.check(v, "premium")["errors"] == []
    x = tmp_path / "a.aif"; x.write_bytes(b"0")
    assert any("不支持的格式" in e for e in P.check(str(x), "free")["errors"])

def test_cut_points():
    assert P.cut_points(20, [2, 5, 7, 10, 15], 8) == [7, 15]
    assert P.cut_points(20, [], 8) == [8, 16]                     # 没有静音时硬切

def test_auto_threshold_finds_pauses():
    # 调试时发现:固定 -35 dB 在 voice_noisy.wav 上一个停顿都找不到,只能硬切在 8.0/16.0 秒
    mids = P.silences("samples/voice_noisy.wav")
    assert len(mids) >= 6 and any(abs(m - 7.07) < 0.2 for m in mids)

def test_split_on_pauses(tmp_path):
    parts = P.split("samples/voice_noisy.wav", 8, str(tmp_path))
    assert len(parts) == 3 and all(p["seconds"] <= 8.1 for p in parts)
    assert abs(sum(p["seconds"] for p in parts) - P.duration("samples/voice_noisy.wav")) < 0.2

第 4 步:测试前自动生成音频的 conftest.py

"""测试前自动生成示例音频(samples/ 不随源码分发)。需要 ffmpeg 和 espeak-ng。"""
import os, subprocess
HERE = os.path.dirname(os.path.abspath(__file__))
def _sh(*cmd): subprocess.run(cmd, cwd=HERE, check=True)
if not os.path.exists(os.path.join(HERE, "samples/voice_noisy.wav")): _sh("bash", "make_audio.sh")
if os.path.exists(os.path.join(HERE, "standin_enhance.sh")) and not os.path.exists(os.path.join(HERE, "samples/voice_standin.wav")):
    _sh("bash", "standin_enhance.sh", "samples/voice_noisy.wav", "samples/voice_standin.wav")
if os.path.exists(os.path.join(HERE, "make_music.sh")) and not os.path.exists(os.path.join(HERE, "samples/music_bed.wav")):
    _sh("bash", "make_music.sh")

第 5 步:上传(手动)

打开 Enhance Speech 页面,依次上传 parts/ 中的文件;处理完成后下载,文件名保持不变。(本文没有实际操作这一步。)

第 6 步:运行

$ bash make_audio.sh
format|duration=20.765458|size=1993562
$ python preflight_split.py samples/voice_noisy.wav --split --max-sec 8 --out parts
{"file": "samples/voice_noisy.wav", "ext": ".wav", "bytes": 1993562, "seconds": 20.8, "errors": []}
   → {'file': 'parts/voice_noisy_part01.wav', 'start': 0.0, 'end': 7.07, 'seconds': 7.08}
   → {'file': 'parts/voice_noisy_part02.wav', 'start': 7.07, 'end': 14.99, 'seconds': 7.94}
   → {'file': 'parts/voice_noisy_part03.wav', 'start': 14.99, 'end': 20.77, 'seconds': 5.75}
合计 0.3 分钟;free 每日额度 60 分钟
$ python -m pytest -q
5 passed

怎么检查结果?

  • 每一段都不超过上限,所有段的时长加起来等于原文件的时长(测试中误差小于 0.2 秒)。
  • 听一下每段的开头和结尾,不应该切在词的中间。
  • 真实的长录音不用加 --max-sec,默认按套餐上限减 5 秒切分。

调试示例

静音检测在有噪声的录音中失效。 第一版使用固定的 silencedetect=noise=-35dB:d=0.4。结果切点正好落在 8.0 和 16.0 秒,说明一个停顿都没找到,只能硬切。

我先测量了底噪:干净录音的第 10 百分位帧电平是 −180 dBFS(数字静音),含噪录音是 −42.2 dBFS,看起来低于 −35。于是再逐个试参数:

noise=-35 d=0.4  -> 0 个停顿
noise=-35 d=0.25 -> 0
noise=-30 d=0.25 -> 8

原因是:句子之间的停顿只有 0.25~0.45 秒,而且停顿里还有回声尾音,电平在 −35 和 −30 dB 之间。

修复:阈值改为“底噪 + 12 dB”(限制在 −60 到 −20 dB 之间),最短停顿改为 0.25 秒。我最初试的是 +10 dB,只找到 2 个停顿,所以改成了 +12 dB。修复后,含噪录音的停顿位置(1.84、4.75、7.07……)和干净录音(1.79、4.69、7.06……)基本一致。

这个 +12 dB 是用这一份测试录音调出来的;你的录音如果切得不好,可以直接把阈值传给 silences(noise_db=...)。

常见问题

现象 原因 处理
提示不支持视频 免费版只能上传音频 用 ffmpeg -i in.mp4 -vn -c:a aac out.m4a 提取音频
切点在句子中间 录音中没有明显的停顿 手动指定阈值,或换一个停顿多的位置
超过每日额度 免费版每天 1 小时 分几天处理
切分后开头有一点爆音 -c copy 切 mp3 时只能对齐到帧 改用 wav,或去掉 -c copy 重新编码

模板规则

  • 先检查,再上传。
  • 长文件在停顿处切开,文件名带上编号。
  • 处理后的文件和原始文件分开保存。

项目资源

用途 地址
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

先在本地切好,再一段段上传,免费额度也能处理长录音。