feat(audio): 音频管理器——3D 衰减、声源池淘汰、分类音量、程序化合成音(beep+oto 纯 Go)
This commit is contained in:
152
internal/audio/audio.go
Normal file
152
internal/audio/audio.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
internal/audio/audio_test.go
Normal file
50
internal/audio/audio_test.go
Normal 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
69
internal/audio/synth.go
Normal 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
|
||||
Reference in New Issue
Block a user