feat(audio): 音频管理器——3D 衰减、声源池淘汰、分类音量、程序化合成音(beep+oto 纯 Go)

This commit is contained in:
NianGao Dev
2026-08-15 23:09:13 +08:00
parent 00397e7b13
commit 4eaab7f3f9
6 changed files with 331 additions and 10 deletions

152
internal/audio/audio.go Normal file
View File

@@ -0,0 +1,152 @@
// Package audio 音频管理器:3D 衰减、声源上限、分类音量(场景/音频.md §2–§4)。
//
// 无音频文件阶段用程序化合成音(正弦/噪声包络);播放后端为 beep + oto(Windows 纯 Go,
// 基于 purego,无需 CGO)。
package audio
import (
"math"
"sync"
"time"
"github.com/ebitengine/oto/v3"
"github.com/faiface/beep"
"github.com/faiface/beep/effects"
"github.com/faiface/beep/speaker"
)
// 声源上限(音频.md §3:同时播放 ≤64,超出按最远淘汰)。
const maxSources = 64
// 音量分类(音频.md §4)。
type category uint8
const (
catSFX category = iota // 音效
catMusic // 音乐
catAmbient // 环境
)
// Manager 音频管理器(单例)。
type Manager struct {
mu sync.Mutex
enabled bool
ctx *oto.Context
master float64
vols [3]float64 // sfx / music / ambient
sources []*source
listener [3]float64 // 玩家位置
}
// source 一个正在播放的声源。
type source struct {
key string
pos [3]float64 // 世界坐标
dist float64 // 距玩家距离
ctrl *beep.Ctrl
cat category
start time.Time
}
// New 创建音频管理器(找不到音频设备时降级为静默——服务器/CI 安全)。
func New() *Manager {
m := &Manager{master: 1.0, vols: [3]float64{1, 1, 1}}
const (
sampleRate = 44100
bufferSize = 100 * time.Millisecond
)
ctx, _, err := oto.NewContext(&oto.NewContextOptions{
SampleRate: sampleRate,
ChannelCount: 2,
BufferSize: bufferSize,
})
if err != nil {
return m // 无设备:静默模式
}
m.ctx = ctx
m.enabled = true
speaker.Init(sampleRate, int(sampleRate*0.1))
return m
}
// SetListener 更新玩家位置(每帧/tick)。
func (m *Manager) SetListener(x, y, z float64) {
m.mu.Lock()
m.listener = [3]float64{x, y, z}
m.mu.Unlock()
}
// SetVolumes 设置分类音量(master × 分类)。
func (m *Manager) SetVolumes(master, sfx, music, ambient float64) {
m.mu.Lock()
m.master, m.vols[catSFX], m.vols[catMusic], m.vols[catAmbient] = master, sfx, music, ambient
m.mu.Unlock()
}
// Play 播放一个音效(3D 衰减,音频.md §3):key 为音效键,pos 为声源世界坐标。
func (m *Manager) Play(key string, x, y, z float64) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.enabled {
return
}
dist := math.Sqrt((x-m.listener[0])*(x-m.listener[0]) + (y-m.listener[1])*(y-m.listener[1]) + (z-m.listener[2])*(z-m.listener[2]))
gain := attenuation(dist)
if gain <= 0.01 {
return // 超出衰减范围:不发声
}
// 声源池:满员时淘汰最远(音频.md §3)
if len(m.sources) >= maxSources {
worst := -1
worstDist := -1.0
for i, s := range m.sources {
if s.dist > worstDist {
worstDist = s.dist
worst = i
}
}
if worst >= 0 {
m.sources[worst].ctrl.Streamer = nil
m.sources[worst].ctrl.Paused = true
m.sources = append(m.sources[:worst], m.sources[worst+1:]...)
}
}
// 程序化合成:按音效键选择波形(无音频文件的占位方案,音频.md §6)
streamer := synthFor(key)
ctrl := &beep.Ctrl{Streamer: streamer, Paused: false}
vol := &effects.Volume{Streamer: ctrl, Base: 2, Volume: math.Log2(0.1+gain)*m.master*m.vols[catSFX] + 1}
speaker.Play(vol)
m.sources = append(m.sources, &source{key: key, pos: [3]float64{x, y, z}, dist: dist, ctrl: ctrl, start: time.Now()})
}
// attenuation 距离衰减:线性,maxDist 20(音频.md §3)。
func attenuation(dist float64) float64 {
const maxDist = 20.0
if dist >= maxDist {
return 0
}
return 1 - dist/maxDist
}
// Close 释放设备。
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
if m.ctx != nil {
_ = m.ctx.Suspend()
m.ctx = nil
}
m.enabled = false
}
// synthFor 按音效键生成占位波形(后续替换为真实音效文件)。
func synthFor(key string) beep.Streamer {
switch key {
case "block.stone.break", "block.grass.break":
return noiseBurst(0.12)
case "block.place", "step":
return sineBurst(220, 0.06)
default:
return sineBurst(440, 0.1)
}
}

View File

@@ -0,0 +1,50 @@
package audio
import "testing"
// TestAttenuation 距离衰减公式(音频.md §3)。
func TestAttenuation(t *testing.T) {
if a := attenuation(0); a != 1 {
t.Fatalf("零距离衰减期望 1,实际 %v", a)
}
if a := attenuation(10); a != 0.5 {
t.Fatalf("10 格衰减期望 0.5,实际 %v", a)
}
if a := attenuation(20); a != 0 {
t.Fatalf("最大距离衰减期望 0,实际 %v", a)
}
if a := attenuation(30); a != 0 {
t.Fatalf("超距衰减期望 0,实际 %v", a)
}
}
// TestBurstStreamer 合成流:有限长度与数据填充。
func TestBurstStreamer(t *testing.T) {
b := sineBurst(440, 0.05)
samples := make([][2]float64, 1024)
total := 0
for {
n, ok := b.Stream(samples)
total += n
if ok {
break
}
}
if total != b.Len() {
t.Fatalf("流长度期望 %d,实际 %d", b.Len(), total)
}
if total < 1000 {
t.Fatalf("采样数异常: %d", total)
}
}
// TestManagerSilent 无音频设备环境(CI/服务器)下降级为静默、不崩溃。
func TestManagerSilent(t *testing.T) {
m := New() // 可能无设备:必须不 panic
defer m.Close()
m.SetVolumes(1, 1, 1, 1)
m.SetListener(0, 0, 0)
for i := 0; i < 100; i++ {
m.Play("block.stone.break", float64(i), 0, 0) // 池满淘汰也不崩
}
}

69
internal/audio/synth.go Normal file
View File

@@ -0,0 +1,69 @@
// 程序化音效合成:正弦脉冲与噪声脉冲(无音频文件的占位实现,音频.md §6)。
package audio
import (
"math"
"math/rand"
"time"
"github.com/faiface/beep"
)
// sineBurst 正弦脉冲(频率 Hz、时长秒)。
func sineBurst(freq float64, dur float64) *burst {
n := int(44100 * dur)
return &burst{n: n, gen: func(t int) float64 {
env := 1 - float64(t)/float64(n) // 线性衰减包络
return env * 0.5 * math.Sin(2*math.Pi*freq*float64(t)/44100)
}}
}
// noiseBurst 噪声脉冲(破坏音效感)。
func noiseBurst(dur float64) *burst {
n := int(44100 * dur)
rng := rand.New(rand.NewSource(42))
return &burst{n: n, gen: func(t int) float64 {
env := 1 - float64(t)/float64(n)
return env * 0.4 * (rng.Float64()*2 - 1)
}}
}
// burst 有限长度合成流。
type burst struct {
n int
pos int
gen func(t int) float64
}
// Stream beep.Streamer:填充采样。
func (b *burst) Stream(samples [][2]float64) (int, bool) {
i := 0
for ; i < len(samples) && b.pos < b.n; i++ {
v := b.gen(b.pos)
samples[i][0] = v
samples[i][1] = v
b.pos++
}
return i, b.pos >= b.n
}
// Err 无错误。
func (b *burst) Err() error { return nil }
// Len 时长采样数。
func (b *burst) Len() int { return b.n }
// Position 当前位置。
func (b *burst) Position() int { return b.pos }
// Seek 跳转。
func (b *burst) Seek(p int) error {
if p < 0 || p > b.n {
p = 0
}
b.pos = p
return nil
}
var _ beep.StreamSeeker = (*burst)(nil)
var _ = time.Second