243 lines
6.6 KiB
Go
243 lines
6.6 KiB
Go
// Package audio 音频管理器:3D 衰减、声源上限、分类音量(场景/音频.md §2–§4)。
|
||
//
|
||
// 无音频文件阶段用程序化合成音(正弦/噪声包络);播放后端为 beep + oto(Windows 纯 Go,
|
||
// 基于 purego,无需 CGO)。
|
||
package audio
|
||
|
||
import (
|
||
"math"
|
||
"math/rand"
|
||
"strings"
|
||
"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
|
||
defaultAtten = 16.0
|
||
captionTTL = 3 * time.Second
|
||
maxCaptions = 4
|
||
)
|
||
|
||
// 音量分类(音频.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 // 玩家位置
|
||
listenerYaw float64 // 水平朝向(弧度,yaw=0 朝 -Z)
|
||
events map[string]*resolvedEvent
|
||
oggCache map[string]*beep.Buffer
|
||
captions []Caption
|
||
rng *rand.Rand
|
||
}
|
||
|
||
// 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}, rng: rand.New(rand.NewSource(time.Now().UnixNano()))}
|
||
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;音频.md §3)。
|
||
func (m *Manager) SetListener(x, y, z, yaw float64) {
|
||
m.mu.Lock()
|
||
m.listener = [3]float64{x, y, z}
|
||
m.listenerYaw = yaw
|
||
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()
|
||
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]))
|
||
if attenuation(dist, defaultAtten) <= 0 {
|
||
return
|
||
}
|
||
m.pushCaption(key, m.subtitleOf(key))
|
||
if !m.enabled {
|
||
return
|
||
}
|
||
pk := m.pick(key, 0)
|
||
params := PlayParams(key)
|
||
atten := defaultAtten
|
||
fileVol, filePitch := 1.0, 1.0
|
||
var streamer beep.Streamer
|
||
if pk.ok {
|
||
streamer = pk.streamer
|
||
fileVol, filePitch = pk.volume, pk.pitch
|
||
if pk.atten > 0 {
|
||
atten = pk.atten
|
||
}
|
||
} else {
|
||
streamer = synthFor(key)
|
||
}
|
||
gain := params.Volume * fileVol * attenuation(dist, atten) * m.master * m.vols[catSFX]
|
||
if gain <= 0.01 {
|
||
return
|
||
}
|
||
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:]...)
|
||
}
|
||
}
|
||
streamer = pitchResample(params.Pitch*filePitch, streamer)
|
||
pan := stereoPan(x-m.listener[0], y-m.listener[1], z-m.listener[2], m.listenerYaw)
|
||
streamer = &panStreamer{Streamer: streamer, l: (1 - pan) / 2, r: (1 + pan) / 2}
|
||
ctrl := &beep.Ctrl{Streamer: streamer, Paused: false}
|
||
vol := &effects.Volume{Streamer: ctrl, Base: 2, Volume: math.Log2(gain)}
|
||
speaker.Play(vol)
|
||
m.sources = append(m.sources, &source{key: key, pos: [3]float64{x, y, z}, dist: dist, ctrl: ctrl, start: time.Now()})
|
||
}
|
||
|
||
// PlayParams 按事件后缀套 Java 调用点音量/音调(音频.md §6.1)。
|
||
func PlayParams(key string) struct{ Volume, Pitch float64 } {
|
||
switch {
|
||
case strings.HasSuffix(key, ".hit"):
|
||
return struct{ Volume, Pitch float64 }{0.25, 0.5}
|
||
case strings.HasSuffix(key, ".break"), strings.HasSuffix(key, ".place"):
|
||
return struct{ Volume, Pitch float64 }{1.0, 0.8}
|
||
case strings.HasSuffix(key, ".step"):
|
||
return struct{ Volume, Pitch float64 }{0.15, 1.0}
|
||
case strings.Contains(key, ".ambient"), strings.Contains(key, ".hurt"), strings.Contains(key, ".death"):
|
||
r1, r2 := rand.Float64(), rand.Float64()
|
||
return struct{ Volume, Pitch float64 }{1.0, (r1-r2)*0.2 + 1}
|
||
default:
|
||
return struct{ Volume, Pitch float64 }{1.0, 1.0}
|
||
}
|
||
}
|
||
|
||
// stereoPan 朝向左右平移:左负右正,听者自身 dist≈0 → 0(音频.md §3,非 HRTF)。
|
||
func stereoPan(dx, dy, dz, yaw float64) float64 {
|
||
dist := math.Sqrt(dx*dx + dy*dy + dz*dz)
|
||
if dist < 0.01 {
|
||
return 0
|
||
}
|
||
localX := dx*math.Cos(yaw) - dz*math.Sin(yaw)
|
||
p := localX / dist
|
||
if p < -1 {
|
||
return -1
|
||
}
|
||
if p > 1 {
|
||
return 1
|
||
}
|
||
return p
|
||
}
|
||
|
||
// panStreamer 按左右增益平衡立体声。
|
||
type panStreamer struct {
|
||
beep.Streamer
|
||
l, r float64
|
||
}
|
||
|
||
func (p *panStreamer) Stream(samples [][2]float64) (n int, ok bool) {
|
||
n, ok = p.Streamer.Stream(samples)
|
||
for i := 0; i < n; i++ {
|
||
samples[i][0] *= p.l
|
||
samples[i][1] *= p.r
|
||
}
|
||
return
|
||
}
|
||
|
||
// attenuation 线性衰减,默认 16 格(音频.md §3)。
|
||
func attenuation(dist, maxDist float64) float64 {
|
||
if maxDist <= 0 {
|
||
maxDist = defaultAtten
|
||
}
|
||
if dist >= maxDist {
|
||
return 0
|
||
}
|
||
if dist <= 0 {
|
||
return 1
|
||
}
|
||
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 {
|
||
case strings.Contains(key, ".break") || strings.Contains(key, ".hit"):
|
||
return noiseBurst(0.12)
|
||
case strings.Contains(key, ".step") || strings.Contains(key, ".place") ||
|
||
strings.Contains(key, "swim") || strings.Contains(key, "splash"):
|
||
return sineBurst(220, 0.06)
|
||
case strings.Contains(key, "eat") || strings.Contains(key, "burp"):
|
||
return noiseBurst(0.08)
|
||
case strings.Contains(key, "hurt"):
|
||
return sineBurst(180, 0.08)
|
||
default:
|
||
return sineBurst(440, 0.1)
|
||
}
|
||
}
|