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

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