Files
ngzz-mc/internal/audio/audio.go

153 lines
4.0 KiB
Go
Raw Normal View History

// 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)
}
}