本节目标:把「音频用什么格式」变成一道可算的题——给定码率、容器开销与片段时长,算出体积,并据此选格式。
站内那套 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_island1153024 字节(约 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 三条可复用判断
- 长片段才值得压。BGM 是体积大头,先压它;短音效压不压都行,别为它引入解码复杂度。
- 有损格式有容器开销。超短片段用有损反而更大,格式表里的
overheadBytes就是提醒你这一点。 - 判据要写进配置。
shortClipMs是策略,不是魔法数——改了它,整包体积立刻可复算。
小结
- 音频是包体里仅次于图集的一块,未压缩 BGM 能到十几 MB。
- D 层配置把格式码率、容器开销、片段时长写成唯一真源;S 层折算体积、按策略选格式。
- 本机实跑:7 个片段从 36599883 字节降到 2530054 字节,省 930‰;ADPCM 与 AAC 的纯体积交点仅 31 毫秒。
- 判据是类型 + 时长的组合:短音效走 PCM 家族(延迟/并发优先),长片段与 BGM 走有损;B 基准证明这个阈值几乎不花体积代价。
- 码率与容器开销是公开可观察近似值;实际随编码器与内容变化。
资源和音频都定下来之后,还有一个绕不开的问题:改了一张图、加了一个关卡,用户要重下多少。这是下一节热更新与版本管理的事。
阅读导航:上一节:图集打包与纹理压缩 · 下一节:资源热更新与版本管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。