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