下载 App
10/161 浏览案例分享
【案例分享】星火音频实战:Play 的频率参数是绝对 Hz,音效池每音 4 个实例,9 个音效全靠 Python 合成
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》开发实录 —— 音频系统
一句话结论
SoundSource:Play(sound, frequency, gain) 里的 frequency 是绝对采样率 Hz,不是 pitch 倍率。 想要 0.95 倍音高,得写 sound:GetFrequency() * 0.95;直接传 0.95,引擎按 0.95 Hz 处理——一段 48000 Hz 的素材会被拉长到 48000 / 0.95 ≈ 14 小时,听感上等于没有声音,而且它不报错。
我按倍率写了一周,排查方向全在"资源没加载"上。
一、音频模型:Sound 是资源,SoundSource 是实例
对象:是什么 关键读数
Sound:音频资源 GetFrequency() 返回素材采样率,只读
SoundSource:挂在节点上的播放实例 GetFrequency() 返回该实例当前绝对播放频率
source:Play(sound) 只在当前 source 频率为 0 时才回填素材采样率。复用同一个 source 播不同采样率的素材,无参重载会沿用上一次的旧值——变调就是这么来的。所以我的代码里永远显式传频率。
0 是 Play 入口的特殊值,引擎拿它去换 sound:GetFrequency()。它不是"不重采样的快速路径",只有当最终播放频率恰好等于设备 mix rate 时,播放步进才是 1:1。
格式支持:LoadWav、LoadOggVorbis、LoadMp3,外加 LoadRaw 喂裸 PCM。我全用 WAV(16-bit PCM 单声道 44100 Hz)。
声道有 5 个:SOUND_MASTER、SOUND_EFFECT、SOUND_AMBIENT、SOUND_VOICE、SOUND_MUSIC。audio:SetMasterGain(type, gain) 按声道设总音量,gain 线性,0 静音、1 满。
二、音效池:每音 4 个实例,加冷却
命中音一秒能响几十次。一个 SoundSource 连播同一个音效,后一次会打断前一次——听感是"哒哒"变成"哒"。所以我给每种音效预建 4 个实例轮转。
-- Sfx.lua 音效池
local Sfx = {}
local DEFS = {
hit = { file = "Audio/hit.wav", gain = 0.22, cd = 0.05 },
pickup = { file = "Audio/pickup.wav", gain = 0.30, cd = 0.035 },
kill = { file = "Audio/kill.wav", gain = 0.30, cd = 0.06 },
crit = { file = "Audio/crit.wav", gain = 0.34, cd = 0.08 },
levelup = { file = "Audio/levelup.wav", gain = 0.40, cd = 0.20 },
dash = { file = "Audio/dash.wav", gain = 0.38, cd = 0.15 },
hurt = { file = "Audio/hurt.wav", gain = 0.20, cd = 0.18 },
over = { file = "Audio/over.wav", gain = 0.45, cd = 1.00 },
win = { file = "Audio/win.wav", gain = 0.45, cd = 1.00 },
}
local VOICES = 4 -- 每种音效的并发实例数
Sfx.pool, Sfx.node, Sfx.ready, Sfx.muted = {}, nil, false, false
function Sfx.Init(node)
Sfx.node = node
for name in pairs(DEFS) do
Sfx.pool[name] = { sources = {}, next = 1, cd = 0, sound = nil, ok = false }
end
Sfx.ready = true
end
-- 惰性加载:首次播放才拉资源,避免启动期并发尖峰
local function ensureLoaded(name)
local e = Sfx.pool[name]
if not e then return false end
if e.ok then return true end
if not Sfx.node then return false end
local d = DEFS[name]
local okRes, snd = pcall(function()
return cache:GetResource("Sound", d.file)
end)
if not (okRes and snd ~= nil) then return false end -- DWP 未下完,下次再试
e.sound, e.ok = snd, true
for i = 1, VOICES do
if not e.sources[i] then
local src = Sfx.node:CreateComponent("SoundSource")
pcall(function() src:SetSoundType(SOUND_EFFECT) end)
e.sources[i] = src
end
end
return true
end
function Sfx.Play(name, pitch)
if Sfx.muted or not Sfx.ready then return end
local e = Sfx.pool[name]
if not e or e.cd > 0 then return end
local d = DEFS[name]
e.cd = d.cd
if not e.ok and not ensureLoaded(name) then return end
local src = e.sources[e.next]
e.next = e.next % VOICES + 1
if not src then return end
local freq = 0
if pitch and pitch ~= 1.0 and e.sound then
local base = 44100
pcall(function() base = e.sound:GetFrequency() end)
freq = base * pitch
end
pcall(function() src:Play(e.sound, freq, d.gain) end)
end
function Sfx.Update(dt)
for _, e in pairs(Sfx.pool) do
if e.cd > 0 then
e.cd = e.cd - dt
if e.cd < 0 then e.cd = 0 end
end
end
end
池子上限 9 × 4 = 36 个 SoundSource,加 BGM 1 个,一共 37 个。实例首次播放时才建,没触发的音效一个都不占。
cd 那一栏不是可选项。命中音在密集怪群里每帧都可能触发,不限流会糊成一堵噪音墙。pickup 取 0.035 秒:后期每秒捡十几个经验球,太长丢了"连着捡"的手感。音量按触发频率反比排:
音效:素材峰值 gain 实际响度 触发频率
hit:0.406 0.22 0.089 每秒几十次
pickup:0.436 0.30 0.131 每秒十几个
hurt:0.590 0.20 0.118 开局被贴住就响
dash:0.331 0.38 0.126 主动触发
levelup:0.452 0.40 0.181 一局几次
kill:0.621 0.30 0.186 击杀时
crit:0.757 0.34 0.257 暴击时
over:0.611 0.45 0.275 一局一次
win:0.766 0.45 0.345 一局一次
hurt 原本给 0.40,实际响度 0.236,是命中音的 2.6 倍。开局被怪贴住就响,在一堆轻音效里非常突兀。玩家原话是"一开始那个不知道是受击音效还是啥的太大声了"。降到 0.20 就对了。
三、BGM:一个 SoundSource,记状态,待播重试
BGM 同时只播一首,一个 SoundSource 够用。它和音效的区别在通道——走 SOUND_MUSIC,这样静音和音量能跟音效分开控。
-- Music.lua 背景音乐
local Music = {}
local TRACKS = {
title = { file = "Audio/bgm_title.mp3", gain = 0.26 },
battle = { file = "Audio/bgm_battle.mp3", gain = 0.30 },
}
Music.current, Music.muted, Music.ready = nil, false, false
function Music.Init(node)
pcall(function()
local src = node:CreateComponent("SoundSource")
pcall(function() src:SetSoundType(SOUND_MUSIC) end)
Music.source = src
end)
Music.ready = true
end
function Music.Play(name)
if not Music.ready or not Music.source or Music.muted then return end
if Music.current == name then return end -- 同一首不重头播
if name == "none" or name == nil then
pcall(function() Music.source:Stop() end)
Music.current = nil
return
end
local d = TRACKS[name]
if not d then return end
if Music.sound == nil or Music._loadedFor ~= name then
local ok, snd = pcall(function()
return cache:GetResource("Sound", d.file)
end)
if not (ok and snd ~= nil) then
Music._pending = name -- 没下完,记下来重试
return
end
Music.sound, Music._loadedFor = snd, name
end
Music._pending, Music.current = nil, name
pcall(function()
Music.source:Play(Music.sound, 0, d.gain) -- 0 = 用素材采样率
end)
end
-- 每帧推进:DWP 未就绪时的重试,0.5 秒一次
function Music.Update(dt)
if Music._pending and not Music.muted then
Music._retry = (Music._retry or 0) + dt
if Music._retry >= 0.5 then
Music._retry = 0
local want = Music._pending
Music._pending = nil
Music.Play(want)
end
end
end
BGM 的 gain 固定 0.26 / 0.30,明显低于音效。这条是从 Vampire Survivors 的一手访谈里抄的教训:EA 版音频太多,玩家玩 30 分钟会头疼。BGM 一旦盖住打击反馈,这游戏就没法玩了。
if Music.current == name then return end 看着多余,其实关键。标题界面每帧都在请求切 title,没有这行就是每帧 Stop + Play,音乐永远卡在第一个音上。
四、音量:设声道总音量,不要逐个改 gain
音效 9 个、BGM 2 首,玩家拉一下滑条要改 11 个 gain——这显然不对。audio:SetMasterGain 按声道设一次就够:
-- Settings.lua 音量与持久化
local Settings = {}
Settings.FILE = "dontstop_settings.json"
Settings.data = { sfxVolume = 0.75, musicVolume = 0.70 }
local function clamp01(v)
if type(v) ~= "number" then return nil end
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
function Settings.Load()
if not fileSystem:FileExists(Settings.FILE) then return end
local f = File(Settings.FILE, FILE_READ)
if not f then return end
local raw = f:ReadString()
f:Dispose()
local ok, d = pcall(cjson.decode, raw)
if not ok or type(d) ~= "table" then return end
local sv, mv = clamp01(d.sfxVolume), clamp01(d.musicVolume)
if sv then Settings.data.sfxVolume = sv end
if mv then Settings.data.musicVolume = mv end
end
function Settings.Save()
local f = File(Settings.FILE, FILE_WRITE)
if not f then return end
f:WriteString(cjson.encode(Settings.data))
f:Dispose()
end
-- 设一次总音量,之后改 gain 即时生效
function Settings.Apply()
pcall(function() audio:SetMasterGain(SOUND_EFFECT, Settings.data.sfxVolume) end)
pcall(function() audio:SetMasterGain(SOUND_MUSIC, Settings.data.musicVolume) end)
end
function Settings.SetSfx(v)
local c = clamp01(v)
if c then Settings.data.sfxVolume = c end
Settings.Apply()
Settings.Save()
end
默认音效 0.75、音乐 0.70。音效素材做过软限幅,满音量偏吵;音乐那边 BGM gain 只有 0.26 到 0.30,再降就听不见了。存档不存在或 JSON 坏了都退回默认值,不报错。
五、踩过的坑
坑 1:音效重叠——同一个 SoundSource 连播会互相打断
-- ❌ 错的:一个音效一个 source,密集触发时后一次打断前一次
local src = node:CreateComponent("SoundSource")
function PlayHit(sound)
src:Play(sound, 0, 0.22)
end
每秒响几十次的命中音,这样写听起来是一串断续的"哒"。
-- ✅ 对的:4 个实例轮流用
local src = e.sources[e.next]
e.next = e.next % VOICES + 1
src:Play(e.sound, 0, e.gain)
坑 2:BGM 切换时机——重复调用会重头播
-- ❌ 错的:状态机每帧调一次,音乐永远卡在开头
function SetState(s)
Music.Play(s == "battle" and "battle" or "title")
end
-- ✅ 对的:记 current,相同就直接返回
if Music.current == name then return end
还有个衍生问题:切场景时 Stop 再 Play,中间有一小段静音。一局里切三四次,玩家能听出来。我的做法是把切换点对齐到玩家注意力本来就在别处的时候。
坑 3:移动端静音——soundType 没设,静音开关就是摆设
-- ❌ 错的:建完 SoundSource 直接用,不设通道
local src = node:CreateComponent("SoundSource")
玩家关掉音效,BGM 也跟着没了;或者反过来,静音了还在响。原因是通道没分开,全落到 SOUND_MASTER 上。
-- ✅ 对的:建的时候就把通道定死
pcall(function() src:SetSoundType(SOUND_EFFECT) end) -- 音效
pcall(function() src:SetSoundType(SOUND_MUSIC) end) -- BGM
SetSoundType 传错不会报错,只会静默落到 master 通道。SetMuted 里还要主动 Stop 已经在播的实例——只置 muted 不叫停,正在响的那一声会响到结束。
坑 4:资源没加载完就播——GetResource 返回 nil
音频是 DWP 媒体资源,首次请求时可能还没下完。直接播就是静音,不报错。
-- ❌ 错的:拿到就播
local snd = cache:GetResource("Sound", "Audio/hit.wav")
src:Play(snd, 0, 0.22) -- snd 是 nil,静默失败
-- ✅ 对的:包 pcall、判 nil、下次播放再试
local okRes, snd = pcall(function()
return cache:GetResource("Sound", d.file)
end)
if not (okRes and snd ~= nil) then return false end -- 下次 Play 再试
BGM 那边更麻烦,因为它是"切过去就该响",没有"下次触发"。所以 Music 里存了一个 _pending,由 Update 每 0.5 秒重试一次。
还有一条反直觉的:别在初始化时预加载全部音频。我原来用 pairs(DEFS) 同步拉全部 8 个音效,加上字体和脚本,启动瞬间就是十几个并发 XHR,实机报错数在 6 / 15 / 19 之间跳。改成首次播放才加载后,启动期音频请求数是 0。
六、程序化生成音效:9 个音效,一个 Python 脚本
整个游戏的音效没用任何现成素材,全是 Python 合成的 WAV——assets/Audio/ 下 9 个文件,共 279 KB。
思路是加法合成:基频 + 若干泛音,每个泛音独立衰减(越高频衰减越快)。
def partial(freq, dur, vol, attack, decay, harm=None, sweep=0.0):
"""harm: [(倍数, 相对音量, 衰减倍数), ...]"""
n = int(RATE * dur)
out = [0.0] * n
phase = [0.0] * len(harm)
for i in range(n):
t = i / n
f0 = freq * (1.0 + sweep * t) # sweep: 滑音
s = 0.0
for k, (mul, hvol, hdec) in enumerate(harm):
phase[k] += 2 * math.pi * f0 * mul / RATE
s += math.sin(phase[k]) * hvol * adsr(i, n, attack, decay * hdec)
out[i] = s * vol
return out
打击类音效还要叠一层噪声。噪声必须用真伪随机(random.Random(seed),固定种子可复现),不能用 hash((i, freq)) 那种——那不是噪声,是周期性杂音,听起来像电子故障。
第一版实机反馈是"有点难听"。四个原因:噪声是周期性的、起音太慢(0.01 秒,打击音丢了瞬态)、全是裸正弦没有泛音、波形突然截断产生直流跳变。改法都在这段收尾函数里:
def finish(samples, fade=0.012, gain=1.0):
n = len(samples)
f = int(RATE * fade)
for i in range(max(0, n - f), n): # 尾部淡出,消咔哒
samples[i] *= (n - i) / f
out, prev_in, prev_out, R = [0.0] * n, 0.0, 0.0, 0.9995
for i in range(n): # 一阶高通,去直流
x = samples[i]
y = R * (prev_out + x - prev_in)
out[i], prev_in, prev_out = y, x, y
peak = max((abs(v) for v in out), default=0.0)
if peak > 0: # 软限幅 + 归一化
k = min(1.0, 0.92 / peak) * gain
out = [math.tanh(v * k * 1.15) for v in out]
return out
起音取 1 到 3 毫秒(打击类),衰减用指数,输出 16-bit PCM 单声道 44100 Hz。
音效:时长 基频 形态
hit:0.045s 2100 Hz 下滑 高频噪声 + 极快起音
pickup:0.055s — 短促上滑
kill:0.17s 520 Hz 下滑 55% 下坠 + 噪声尾
crit:0.20s 1760 / 2640 Hz 明亮金属,泛音堆叠
levelup:0.52s 659→784→1046 大三和弦上行
dash:0.20s 300 Hz 上滑 气流呼啸,噪声为主
hurt:0.24s 120 Hz 下滑 低沉闷响
over:0.85s 392 / 294 Hz 下行长音
win:0.90s 523/659/784/1046 上行和弦同时收
写完音效我还写了个脚本量 BGM:ffmpeg 把 mp3 转成 22050 Hz 单声道 PCM,再算五个指标——RMS 能量与动态、低频能量占比(一阶低通,截止约 200 Hz)、过零率、瞬态密度。
反馈"音乐不够激情"的时候,与其反复重生成碰运气,不如先量出问题在哪。高能量电子舞曲的参考区间是 BPM 128 到 160、低频占比高、瞬态密集。
适用边界
适合:
- 音效种类在 20 个以内、每种触发频率差别大(从每秒几十次到一局一次)
- 没有音频美术资源,或不想为几个短音效谈授权
- 需要音高随机化避免重复感(同一个音效每次 ±7% 音高)
- 移动端优先,不能接受大体积音频包
不适合:
情况:原因
需要人声、真实乐器采样:加法合成做不出这个
需要长混响、空间感:手机外放会糊成一团
音效超过几十种:手工调 DEFS 那张表会失控,该上音频中间件
需要 3D 空间音频:得用 SoundSource3D + SoundListener,那是另一套
程序化合成的边界更清楚:它解决"有没有声音",不解决"好不好听"。 做到第九个音效时我已经听出这套合成器的天花板——泛音结构就那几种,再堆也堆不出层次。
SoundSource 必须挂在节点上。我这是纯 NanoVG 项目,没有自己的 Scene,专门建了个不参与渲染的 Scene 和节点承载音频源,并持有引用——不然被 GC 回收,声音就没了。
可迁移的经验
凡是叫 frequency 的参数,先查它是不是绝对 Hz。 采样率、频率、音高倍率这三个概念在不同引擎里混用得厉害。判断方法:看它有没有"传 0 表示用默认"的约定——有这种约定的基本都是绝对频率。
并发上限是数出来的,不是配出来的。 9 种音效 × 4 实例 = 36,加 BGM 1 个,一共 37 个 SoundSource。这个数写进注释,以后加音效时一眼就知道会不会翻倍。
触发频率决定音量。 素材峰值乘以 gain 算出实际响度,再按触发频率从高到低排一遍。排完你会发现最响的那个往往最不该响——我的 hurt 就是这么被抓出来的。
没有美术资源的时候,程序化生成是能打的。 一个 200 行的 Python 脚本,9 个音效,279 KB。做不了人声和真实乐器,打击反馈、升级提示、死亡音够用。
静音功能要单独测。 通道没设对时不报错,只是行为诡异。测法:关掉音效听 BGM 还在不在,关掉 BGM 听音效还在不在。
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复