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