4.2 音频格式与体积权衡

音频常常是包体里仅次于图集的一块,却最容易被「随便导出个 MP3」糊弄过去。本节把音频格式折算成可算的字节:码率、容器开销、片段时长一起进公式,再用一段脚本选出每个片段该用的格式,并给出「什么时长用什么格式」的判据,算清短音效与长片段各自该走哪条路。

本节目标:把「音频用什么格式」变成一道可算的题——给定码率、容器开销与片段时长,算出体积,并据此选格式。

站内那套 127 篇的 Phaser 引擎专书 里,音频是 this.sound.add('sfx') 这样一行加载代码,讲的是引擎怎么加载与播放。本节不碰播放 API,而是讲可跑的音频体积核算与格式选择模型:同一段音频,用 WAV、ADPCM 还是 AAC,体积差多少,什么时候该用哪个——这些都能在本机算出确定数字。

4.2 音频格式与体积权衡

4.2.1 音频是包体里最容易被低估的一块

图集看得见,音频看不见,于是音频经常是最后一个被想起来优化的东西。但它的量级很吓人:一首 96 秒的 BGM,用未压缩的 44.1kHz 立体声 WAV,就是 16 MB 上下——比整张图集大一个数量级。小游戏的主包预算常常只有几 MB,音频不压缩基本没得谈。

音频压缩的取舍比图集复杂,因为它是两段式的:文件字节(传输/存储)与解码开销(CPU/延迟)。前者是码率问题,后者是格式问题。本节两样都算。

4.2.2 D 层:格式表与片段清单

下面这份是 D 层配置:一张格式表(码率 + 容器开销)与一份片段清单(时长 + 类型)。格式表里的 bitrateKbps 是每千比特每秒,overheadBytes 是容器/编码器附加的固定开销——它决定了「超短片段用有损编码反而更大」。

{
  "observation": "公开可观察,观察时点 2026-10;码率与容器开销为公开近似值,实际随编码器与内容变化",
  "policy": {
    "shortClipMs": 1000,
    "probeClip": "bgm_island",
    "sweepMaxMs": 120000,
    "sweepStepMs": 2000
  },
  "formats": [
    {"id": "wav16_stereo", "label": "WAV 16bit 立体声 44.1k", "family": "pcm", "bitrateKbps": 1411, "overheadBytes": 44},
    {"id": "wav16_mono", "label": "WAV 16bit 单声道 44.1k", "family": "pcm", "bitrateKbps": 706, "overheadBytes": 44},
    {"id": "adpcm_mono", "label": "IMA-ADPCM 单声道", "family": "pcm", "bitrateKbps": 353, "overheadBytes": 60},
    {"id": "mp3_128", "label": "MP3 128kbps", "family": "lossy", "bitrateKbps": 128, "overheadBytes": 2048},
    {"id": "mp3_96", "label": "MP3 96kbps", "family": "lossy", "bitrateKbps": 96, "overheadBytes": 2048},
    {"id": "aac_96", "label": "AAC-LC 96kbps", "family": "lossy", "bitrateKbps": 96, "overheadBytes": 1024},
    {"id": "ogg_96", "label": "Ogg Vorbis ~96kbps", "family": "lossy", "bitrateKbps": 96, "overheadBytes": 1024}
  ],
  "clips": [
    {"id": "ui_click", "kind": "sfx", "durationMs": 90},
    {"id": "merge_pop", "kind": "sfx", "durationMs": 320},
    {"id": "combo_up", "kind": "sfx", "durationMs": 700},
    {"id": "level_clear", "kind": "sfx", "durationMs": 1400},
    {"id": "bgm_island", "kind": "bgm", "durationMs": 96000},
    {"id": "bgm_shop", "kind": "bgm", "durationMs": 64000},
    {"id": "bgm_event", "kind": "bgm", "durationMs": 45000}
  ]
}

片段清单里 kind 分两类:sfx(短促、高频、可能多路并发的音效)与 bgm(长、单路播放的背景音乐)。policy.shortClipMs 是「音效 / 音乐」的分界阈值,也是本节要论证的那个判据。

4.2.3 S 层:体积核算与格式选择

下面这段是 S 层仿真脚本。它按 bytes = overhead + bitrateKbps * durationMs // 8 折算每个 (片段, 格式) 的体积,再按策略挑格式:短音效走 PCM 家族,长片段走有损编码。它同时对比「全部用 WAV 立体声」的朴素基线,并求 ADPCM 与 AAC 的体积交点时长。

#!/usr/bin/env python3
# file: sim/e04_audio.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e04_audio.py --config config/ --out out/
"""音频体积核算与「什么时长用什么格式」判据。

读 config/e04_audio.json:格式表(码率 + 容器开销)+ 音频清单 + 策略。
1) 对每个 (片段, 格式) 折算体积:bytes = overhead + bitrateKbps * durationMs // 8;
2) 按策略挑格式:短音效走 PCM 家族(低延迟、可无缝叠播),长 BGM 走有损编码;
3) 对比「全部用 WAV 立体声」的朴素基线,给出节省;
4) 求 ADPCM 与 AAC 的体积交点时长——低于它 PCM 家族不亏,高于它有损编码才赢。
全程整数运算(字节 / 毫秒)。产出 out/e04_audio.json + out/e04_audio.csv。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys


def load(cfg_dir, name):
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def clip_bytes(fmt, duration_ms):
    """体积 = 容器开销 + 码率 * 毫秒 // 8(整数)。"""
    return fmt["overheadBytes"] + fmt["bitrateKbps"] * duration_ms // 8


def pick_format(clip, formats, policy):
    """按片段类型 + 时长选格式:短音效走 PCM 家族(低延迟、可叠播),其余走有损。

    时长阈值不是体积最优点(体积最优点会想压掉一切),而是「解码延迟与同时发声数」
    与「体积」之间的工程折中:短促、高频、可能多路并发的音效留在 PCM 家族,
    长片段或单路播放的走有损编码。
    """
    is_short_sfx = clip["kind"] == "sfx" and clip["durationMs"] < policy["shortClipMs"]
    fam = "pcm" if is_short_sfx else "lossy"
    cand = [f for f in formats if f["family"] == fam]
    return min(cand, key=lambda f: clip_bytes(f, clip["durationMs"]))


def crossover_ms(pcm_fmt, lossy_fmt):
    """求两种格式体积相等的时长(毫秒):overhead + k*d/8 相等。

    整数解:d = (overhead_lossy - overhead_pcm) * 8 // (k_pcm - k_lossy)。
    """
    denom = pcm_fmt["bitrateKbps"] - lossy_fmt["bitrateKbps"]
    if denom <= 0:
        return 0
    num = (lossy_fmt["overheadBytes"] - pcm_fmt["overheadBytes"]) * 8
    return max(0, (num + denom - 1) // denom)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config, "e04_audio")
    formats, clips, policy = cfg["formats"], cfg["clips"], cfg["policy"]
    by_id = {f["id"]: f for f in formats}
    os.makedirs(args.out, exist_ok=True)

    rows, chosen, total_smart, total_baseline = [], [], 0, 0
    baseline_fmt = by_id["wav16_stereo"]
    for clip in clips:
        sizes = {f["id"]: clip_bytes(f, clip["durationMs"]) for f in formats}
        best = pick_format(clip, formats, policy)
        base = clip_bytes(baseline_fmt, clip["durationMs"])
        total_smart += sizes[best["id"]]
        total_baseline += base
        chosen.append({"id": clip["id"], "kind": clip["kind"], "durationMs": clip["durationMs"],
                       "chosen": best["id"], "chosenBytes": sizes[best["id"]],
                       "baselineBytes": base, "sizes": sizes})
        for f in formats:
            rows.append({"clip": clip["id"], "kind": clip["kind"], "durationMs": clip["durationMs"],
                         "format": f["id"], "bytes": sizes[f["id"]]})

    cross_ms = crossover_ms(by_id["adpcm_mono"], by_id["aac_96"])

    with open(os.path.join(args.out, "e04_audio.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["clip", "kind", "durationMs", "format", "bytes"])
        for r in rows:
            w.writerow([r["clip"], r["kind"], r["durationMs"], r["format"], r["bytes"]])

    report = {
        "source": "sim/e04_audio.py", "observation": cfg["observation"], "policy": policy,
        "chosen": chosen, "totalSmartBytes": total_smart, "totalBaselineBytes": total_baseline,
        "savePerMille": (total_baseline - total_smart) * 1000 // max(1, total_baseline),
        "crossoverMs": cross_ms, "crossoverSec": cross_ms // 1000,
        "csv": os.path.join(args.out, "e04_audio.csv"),
    }
    with open(os.path.join(args.out, "e04_audio.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    for c in chosen:
        print(f"{c['id']:>11} {c['kind']:>3} {c['durationMs']:>6}ms -> {c['chosen']:>12} "
              f"{c['chosenBytes']:>7}B (baseline {c['baselineBytes']}B)")
    print(f"total smart={total_smart}B baseline={total_baseline}B "
          f"save={(total_baseline - total_smart) * 1000 // max(1, total_baseline)}permille")
    print(f"ADPCM vs AAC 交点 = {cross_ms}ms ({cross_ms // 1000}s)")
    return 0


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

本机实跑输出:

   ui_click sfx     90ms ->   adpcm_mono    4031B (baseline 15917B)
  merge_pop sfx    320ms ->   adpcm_mono   14180B (baseline 56484B)
   combo_up sfx    700ms ->   adpcm_mono   30947B (baseline 123506B)
level_clear sfx   1400ms ->       aac_96   17824B (baseline 246969B)
 bgm_island bgm  96000ms ->       aac_96 1153024B (baseline 16932044B)
   bgm_shop bgm  64000ms ->       aac_96  769024B (baseline 11288044B)
  bgm_event bgm  45000ms ->       aac_96  541024B (baseline 7936919B)
total smart=2530054B baseline=36599883B save=930permille
ADPCM vs AAC 交点 = 31ms (0s)

逐条读这份输出:

  • 三个短音效走 ADPCM:ui_click(90ms) 4031 字节、merge_pop(320ms) 14180 字节、combo_up(700ms) 30947 字节。它们都短于 1 秒阈值,留在 PCM 家族。
  • level_clear(1400ms) 越过阈值,改走 AAC:17824 字节,而 PCM 单声道要 123 KB 上下。一段 1.4 秒的过关音效,压一下省了约 10 万字节。
  • 三首 BGM 全走 AAC:96 秒的 bgm_island 1153024 字节(约 1.1 MB),未压缩基线是 16932044 字节(约 16 MB)。
  • 总包从 36599883 字节降到 2530054 字节,省 930‰。省下来的几乎全在 BGM 上——长片段才是体积大头。

4.2.4 什么时长用什么格式:判据

脚本算出一个反直觉的数字:ADPCM 与 AAC 的体积交点只有 31 毫秒。也就是说,如果只看体积,只要片段长过 31 毫秒,AAC 就已经比 ADPCM 小——按体积最优,所有音效都该压成 AAC。

那为什么工程上还把短音效留在 PCM 家族?因为体积不是唯一的轴:

  • 解码延迟。有损编码器有解码预热(priming)样本,播放起点不精确;短音效对「按下即响」的时序最敏感。
  • 同时发声数。一次合成可能叠好几个音效,每路一个 AAC 解码器,CPU 与内存开销远高于 PCM/ADPCM。
  • 绝对体积小。短音效本身才几 KB,压不压都无所谓;把复杂度花在这里不值。

所以判据是类型 + 时长的组合,不是纯体积最优:

片段判据选
短音效(< 1 秒、可能叠播)延迟与并发优先,体积可忽略PCM / ADPCM
长音效、过关音乐(≥ 1 秒)单路播放,体积开始显著有损(AAC/MP3)
BGM体积绝对优先有损(AAC/MP3)

这个 1 秒阈值是可调的工程折中,不是物理常数——接下来的基准会告诉你调它有多大影响。

4.2.5 B 基准:阈值扫一遍

下面这段是 B 层基准脚本。它复用 S 层的折算与挑选逻辑,把「音效 / 音乐」分界阈值从 0 毫秒扫到 2000 毫秒,复算总包体积;对体积超过最省方案 2 倍的档位给软告警,不硬失败。

#!/usr/bin/env python3
# file: sim/e04_audio_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e04_audio_bench.py --config config/ --out out/
"""音频分界阈值的敏感度基准(软告警,不硬失败):扫 shortClipMs,看总包体积怎么变。"""

from __future__ import annotations

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from e04_audio import clip_bytes, load, pick_format  # noqa: E402

THRESHOLDS_MS = (0, 250, 500, 1000, 2000)


def total_for(threshold_ms, formats, clips):
    """给定分界阈值,复算总包体积与走 PCM 的片段数。"""
    total, pcm = 0, 0
    for clip in clips:
        best = pick_format(clip, formats, {"shortClipMs": threshold_ms})
        total += clip_bytes(best, clip["durationMs"])
        pcm += 1 if best["family"] == "pcm" else 0
    return total, pcm


def main():
    cfg = load("config/", "e04_audio")
    os.makedirs("out/", exist_ok=True)
    rows = [{"thresholdMs": th, **dict(zip(("totalBytes", "pcmClips"), total_for(th, cfg["formats"], cfg["clips"])))}
            for th in THRESHOLDS_MS]
    best = min(r["totalBytes"] for r in rows)
    warns = [f"阈值 {r['thresholdMs']}ms 总包 {r['totalBytes']}B 超过最省方案 {best}B 的 2 倍"
             for r in rows if r["totalBytes"] > best * 2]
    report = {"source": "sim/e04_audio_bench.py", "tier": "B", "bestBytes": best,
              "rows": rows, "warnings": warns, "ok": not warns}
    with open("out/e04_audio_bench.json", "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    for r in rows:
        print(f"threshold={r['thresholdMs']}ms total={r['totalBytes']}B pcmClips={r['pcmClips']}")
    for w in warns:
        print(f"  WARN {w}")
    print(f"== 音频阈值基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)")
    return 0


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

本机实跑输出:

threshold=0ms total=2497288B pcmClips=0
threshold=250ms total=2499215B pcmClips=1
threshold=500ms total=2508531B pcmClips=2
threshold=1000ms total=2530054B pcmClips=3
threshold=2000ms total=2574065B pcmClips=4
== 音频阈值基准:OK(软告警不硬失败)

基准说明:阈值从 0(全部有损)拉到 2000 毫秒,总包只从 2497288 字节涨到 2574065 字节——只涨了约 3%。换句话说,把短音效留在 PCM 家族几乎不花体积代价,却换来了低延迟与低并发开销。这就是「阈值定在 1 秒」站得住脚的理由:它便宜。

4.2.6 三条可复用判断

  1. 长片段才值得压。BGM 是体积大头,先压它;短音效压不压都行,别为它引入解码复杂度。
  2. 有损格式有容器开销。超短片段用有损反而更大,格式表里的 overheadBytes 就是提醒你这一点。
  3. 判据要写进配置。shortClipMs 是策略,不是魔法数——改了它,整包体积立刻可复算。

小结

  • 音频是包体里仅次于图集的一块,未压缩 BGM 能到十几 MB。
  • D 层配置把格式码率、容器开销、片段时长写成唯一真源;S 层折算体积、按策略选格式。
  • 本机实跑:7 个片段从 36599883 字节降到 2530054 字节,省 930‰;ADPCM 与 AAC 的纯体积交点仅 31 毫秒。
  • 判据是类型 + 时长的组合:短音效走 PCM 家族(延迟/并发优先),长片段与 BGM 走有损;B 基准证明这个阈值几乎不花体积代价。
  • 码率与容器开销是公开可观察近似值;实际随编码器与内容变化。

资源和音频都定下来之后,还有一个绕不开的问题:改了一张图、加了一个关卡,用户要重下多少。这是下一节热更新与版本管理的事。

阅读导航:上一节:图集打包与纹理压缩 · 下一节:资源热更新与版本管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制