2026-08-15 23:09:13 +08:00
|
|
|
|
// 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 (
|
2026-08-15 23:17:34 +08:00
|
|
|
|
catSFX category = iota // 音效
|
|
|
|
|
|
catMusic // 音乐
|
|
|
|
|
|
catAmbient // 环境
|
2026-08-15 23:09:13 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-08-15 23:17:34 +08:00
|
|
|
|
key string
|
|
|
|
|
|
pos [3]float64 // 世界坐标
|
|
|
|
|
|
dist float64 // 距玩家距离
|
|
|
|
|
|
ctrl *beep.Ctrl
|
|
|
|
|
|
cat category
|
|
|
|
|
|
start time.Time
|
2026-08-15 23:09:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|