153 lines
4.0 KiB
Go
153 lines
4.0 KiB
Go
// 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)
|
||
}
|
||
}
|