跳转到内容

音频

Estella 的音频通过 Audio 资源播放一次性音效和循环音乐,用句柄控制单个声音,用总线层级 平衡混音,并用空间音频把声音定位到世界里。它在浏览器上跑 Web Audio 后端,在微信上跑原生后端。

Audio 是逐 App 的资源,默认已注册。音频文件会被解码成缓冲区并在首次使用时缓存,然后 在某个总线(命名的音量分组)上播放。每次播放返回一个 AudioHandle,你可以留着它来调节 或停止这一个声音。对于绑定到某个位置或物体的声音,AudioSource 组件声明式地播放,并在 (spatial)开启时按与 AudioListener 的距离衰减。

解码后的缓冲区放在一个按字节预算的热缓存里(默认 32 MB, RuntimeConfig.audioCacheBudget,构建配置里为 audioCacheBudget)——纹理预算的音频镜像: 被资源生命周期释放的缓冲区仍可即时播放,再次加载时原地复活而不用重新下载和解码;超出预算时 最旧的未使用缓冲区被丢弃。收到系统内存警告时引擎会自动清空整个热缓存——正在播放的声音不受 影响。

import { defineSystem, Res, Audio } from 'esengine';
const play = defineSystem([Res(Audio)], (audio) => {
const sfx = audio.playSFX('assets/hit.wav', { volume: 1, pitch: 1, pan: 0 });
audio.playBGM('assets/theme.ogg', { fadeIn: 0.5, crossFade: 1.0 });
});

playSFX 触发一次性播放并返回一个句柄;playBGM 循环并替换当前曲目,可选交叉淡入。两者都会 自动预加载未缓存的片段并在就绪后播放。

方法 说明
playSFX(url, config?) 播放一次性;返回 AudioHandle。config:volumepitchpanpriority
playBGM(url, config?) 循环音乐,替换当前曲目。config:volumefadeIncrossFade
stopBGM(fadeOut?) 停止音乐,可在 fadeOut 秒内淡出。
stopAll() 停止所有正在播放的声音。
setMasterVolume(v) 设置 master 总线音量(0..1)。
setMusicVolume(v) 设置 music 总线音量。
setSFXVolume(v) 设置 sfx 总线音量。
setUIVolume(v) 设置 ui 总线音量。
muteBus(name, muted) 按名字静音/取消静音一个总线。
preload(url) 提前解码并缓存一个片段(异步)。
preloadAll(urls) 预加载一组片段(异步)。
getBufferHandle(url) url 的已缓存缓冲区,或 undefined
getSpectrum(out) 用主输出的频谱填充一个 Uint8Array(每个频段 0–255,从低频到高频),供可视化用。在没有分析能力的后端(微信)上返回 false——按静音处理。
setBufferBudget(bytes) 覆盖热缓存字节预算(null 回到 RuntimeConfig 的值;0 关闭缓存)。
getBufferStats() 驻留计数:bufferCountbufferBytesbufferBudgetevictableCount
trimBufferCache() 立即释放所有未使用的缓存缓冲区(引擎在系统内存警告时会自动调用)。

playSFX 返回一个 AudioHandle——留着它驱动这个特定的声音:

const shot = audio.playSFX('assets/laser.wav');
shot.setVolume(0.6);
shot.setPan(-0.5); // -1 left … +1 right
shot.setPlaybackRate(1.2); // pitch / speed
shot.setLoop(true);
shot.pause(); shot.resume(); shot.stop();
shot.onEnd = () => { /* the sound finished */ };
成员 类型 说明
setVolume(v) 方法 音量,0..1。
setPan(p) 方法 立体声声像,-1(左)…+1(右)。
setPlaybackRate(r) 方法 播放速度 / 音调(1 = 正常)。
setLoop(l) 方法 循环该声音。
pause() / resume() / stop() 方法 传输控制。
onEnd 回调 声音结束时调用一次。
isPlaying 只读 是否正在播放。
currentTime 只读 播放位置(秒)。
duration 只读 总时长(秒)。

给实体加一个 AudioSource 即可不写代码播放——非常适合环境音、循环的机器声,或锚定到物体的 声音。配 playOnAwake + enabled,音频系统会自动开始播放。

属性 类型 默认 说明
clip asset '' 要播放的音频资源。
bus string 'sfx' 路由经过的混音总线。
volume number 1 音量,0..1。
pitch number 1 播放速度 / 音调。
loop boolean false 循环片段。
playOnAwake boolean false 实体启用时自动开始。
spatial boolean false 按与监听器的距离衰减。
minDistance number 100 在此距离内音量为满。
maxDistance number 1000 超过此距离即静音。
attenuationModel AttenuationModel Inverse 衰减曲线——Linear / Inverse / Exponential
rolloff number 1 衰减陡峭度的倍率。
priority number 0 池满时的声部优先级(高者胜)。
enabled boolean true 关掉即停止并静音,而不移除组件。

混音器是层级的——master → { music, sfx, ui, voice }。设置某个总线的音量会缩放路由到它下面 的一切:

audio.setMasterVolume(0.8);
audio.setMusicVolume(0.6);
audio.setSFXVolume(1.0);
audio.setUIVolume(0.9);
audio.muteBus('music', true);

AudioSource.bus 字段把声音路由到某个总线,或让 playSFX 默认走 sfx。音量设置器有 setMasterVolume(全局滑条)和 setMusicVolume / setSFXVolume / setUIVolumevoice 总线能路由声音但没有音量设置器——用 muteBus('voice', …) 开关它。

每条总线携带一条 DSP 插入链,以数据声明:

audio.setBusEffects('music', [
{ type: 'filter', filter: 'lowpass', frequency: 800, q: 1 }, // 闷化(暂停菜单)
{ type: 'reverb', seconds: 1.5, wet: 0.3 }, // 程序化房间混响
{ type: 'compressor', thresholdDb: -24, ratio: 4 },
]);
audio.setBusEffects('music', []); // 清空

旁链闪避是一条规则,不是代码——语音播放时自动压低音乐:

audio.setBusDucking('music', { trigger: 'voice', amount: 0.3, attack: 0.05, release: 0.4 });
audio.setBusDucking('music', null); // 移除

trigger 总线有信号时,目标总线的闪避级压到 amount;静默后按 release 恢复到 1。 闪避是独立的增益级,不会与用户音量设置打架。无 WebAudio 图的后端(微信)上, 两个 API 与音量调用一样优雅降级为空操作。

底部停靠的音频混音器面板可视化编辑以上一切——每总线一条 strip:音量推子、 静音、效果链、闪避规则、自定义总线。编辑持久化到 project.esproject (features.audio)并在编辑器中即时生效;Play 与所有导出以同一套混音启动。

选中音频资产会显示解码波形(可播放/点击跳转)和导入设置:CompressBitrate 控制打包时的 WAV → MP3 转码(在打包对话框勾选压缩音频)。 已压缩格式原样通过。MP3 有少量编码器延迟——需要无缝循环的素材请关闭 Compress

Audio 是一个门面,其后是 Web Audio 后端组装的三个导出类——AudioMixerAudioBusAudioPool。游戏里很少直接构造它们(微信后端上它们根本不存在,这正是总线 API 在那里 降级为空操作的原因),但它们公开导出是为了两件实事:内建四条之外的自定义总线,以及 独立音频管线——试听工具、自定义 PlatformAudioBackend、测试——建在你自己的 AudioContext 上。

自定义总线完全不需要类——门面就够:

audio.ensureBus('ambience'); // create under master (idempotent)
audio.ensureBus('footsteps', 'sfx'); // or under an existing bus
audio.setBusVolume('ambience', 0.5);
// playSFX 始终走 'sfx' 总线;playTrack(或实体的 AudioSource.bus)才能选总线。
audio.playTrack('assets/wind.ogg', { bus: 'ambience' });

ensureBus(name, parent?) 在总线缺失时创建它(挂在 parent 下,默认 master), 无混音图的后端上返回 false;之后所有按名字的门面调用(setBusVolumemuteBussetBusEffectssetBusDucking)都能触达它。

一条混音 strip:input → [效果插入…] → duck → gain(volume) → parent。声源和子总线 连到 input;node输出增益——音量/静音作用在这里,父总线(或做 VU 表的 AnalyserNode)也从这里取信号。闪避级是独立的增益,所以旁链闪避永远不会与用户音量设置 打架。用 AudioBusConfig 构造:

AudioBusConfig 类型 默认 说明
name string 总线名。
volume number 1 初始音量,钳制到 0..1。
muted boolean false 以静音启动。
parent string 'master' 父总线名(由 AudioMixer.createBus 消费)。
成员 说明
input / node 入口 GainNode / 输出 GainNode(analyser 从 node 取信号)。
volume 0..1;变化在约 15 ms 内平滑,永不爆音。
muted 把输出渐变到 0 再回来,不丢失 volume
effects / setEffects(defs) 读取 / 幂等替换 DSP 插入链(BusEffectDef[])。
duckTo(level, timeConstant) 把闪避级向 level 渐变(混音器的闪避驱动它)。
connect(dest) / addChild(bus) 把输出接进父总线或裸 AudioNode

拥有总线树。构造一个就建好接到 context.destinationmaster → { music, sfx, ui, voice },音量来自 AudioMixerConfig (masterVolume / musicVolume / sfxVolume / uiVolume / voiceVolume—— music 默认 0.8,其余 1):

import { AudioMixer } from 'esengine';
const mixer = new AudioMixer(new AudioContext(), { musicVolume: 0.6 });
const ambience = mixer.createBus({ name: 'ambience' }); // under master
方法 说明
master / music / sfx / ui / voice 内建总线,作为只读字段。
getBus(name) / busNames() 查找总线 / 列出所有名字(创建顺序,master 在先)。
createBus(config) 创建总线并接到 config.parent 下(默认 master)。
setDucking(target, rule | null) / getDucking(target) 安装或清除旁链 BusDuckRule(triggeramountattackreleasethreshold)。
updateDucking() 每帧:测量各触发总线的 RMS,渐变其目标的闪避级(引擎的混音器由音频系统调用)。

一次性播放背后的声部池:预建的 gain → panner 节点对(PooledAudioNode),每次播放 acquire()、声音结束时 release(),超过初始 16 个时按需增长;activeCountcapacity 报告用量。当你在自己的 AudioContext 上做自定义播放、想避免每次播放的节点 开销时可以用它。

AudioSource.spatial = true,并给监听实体(通常是相机或玩家)加一个启用的 AudioListener。 声源便按距离衰减:

import { defineSystem, Commands, Transform, AudioSource, AudioListener,
AttenuationModel } from 'esengine';
const setupSpatial = defineSystem([Commands()], (cmds) => {
// A positioned, looping ambience that fades with distance.
cmds.spawn()
.insert(Transform, { position: { x: 600, y: 0, z: 0 } })
.insert(AudioSource, {
clip: 'assets/campfire.ogg', loop: true, playOnAwake: true,
spatial: true, minDistance: 80, maxDistance: 900,
attenuationModel: AttenuationModel.Inverse,
});
// The listener (attach to the camera/player).
cmds.entity(cameraEntity).insert(AudioListener, { enabled: true });
});
AttenuationModel 衰减
Linear minDistancemaxDistance 的直线。
Inverse(默认) 自然的反距离衰减。
Exponential 更陡的指数衰减。

AudioListener 只有一个字段 enabled(默认 true)——场景里保持恰好一个启用的监听器。

Estella 的空间管线是诚实的 2D:每帧,音频系统取声源和监听器的世界位置,算出距离,用 volume × 衰减 加上沿 x 轴的立体声声像((sourceX − listenerX) / maxDistance, 钳制到 ±1)驱动正在播放的句柄。没有 HRTF、多普勒或遮挡。空间声源在没有启用监听器时播放, 会警告一次并从世界原点量距离。

曲线数学以纯函数导出,供工具和测试使用——calculateAttenuation(distance, config)SpatialAudioConfig(modelrefDistancemaxDistancerolloff——系统用 AudioSource.minDistance/maxDistance/rolloff 填充它),以及 calculatePanning(sourceX, sourceY, listenerX, listenerY, maxDistance):

import { calculateAttenuation, calculatePanning, AttenuationModel } from 'esengine';
const gain = calculateAttenuation(250, {
model: AttenuationModel.Inverse, refDistance: 100, maxDistance: 1000, rolloff: 1,
}); // 0.4 — refDistance / distance
const pan = calculatePanning(600, 0, 0, 0, 1000); // 0.6 — to the right
  • 对在关键时刻要播放的片段预加载(preload / preloadAll),第一次播放就不会因解码而静音。
  • 在手势上开始——浏览器在第一次用户输入前会屏蔽音频。
  • 分开总线(music / sfx / ui / voice)让玩家能各自调节,并统一由 master 驱动。
  • AudioSource 上的 priority 限制同时播放的声音数,声部池满时重要的声音才胜出。
  • 世界/物体声音AudioSource(声明式),一次性的 UI/玩法音效用 playSFX
  • 资源 —— 加载与打包音频片段。
  • 场景 —— AudioSource 状态随场景持久化。