Files
ngzz-mc/internal/audio/audio.go
2026-09-19 14:21:08 +08:00

243 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}