feat(audio): 音频管理器——3D 衰减、声源池淘汰、分类音量、程序化合成音(beep+oto 纯 Go)

This commit is contained in:
NianGao Dev
2026-08-15 23:09:13 +08:00
parent 00397e7b13
commit 4eaab7f3f9
6 changed files with 331 additions and 10 deletions

26
go.mod
View File

@@ -2,14 +2,24 @@ module mc
go 1.26.4
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/ebitengine/oto/v3 v3.4.0
github.com/faiface/beep v1.1.0
github.com/fsnotify/fsnotify v1.10.1
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18
github.com/klauspost/compress v1.19.2
github.com/ojrac/opensimplex-go v1.0.2
github.com/yuin/gopher-lua v1.1.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/ojrac/opensimplex-go v1.0.2 // indirect
github.com/yuin/gopher-lua v1.1.2 // indirect
golang.org/x/sys v0.13.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/hajimehoshi/oto v0.7.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect
golang.org/x/sys v0.36.0 // indirect
)

44
go.sum
View File

@@ -1,17 +1,57 @@
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18 h1:c/72gzBO3eZPUVuHPnfyvt79XoIEi7KYGBY/8J/BpeI=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260802143932-8fa725040a18/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk=
github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
github.com/ojrac/opensimplex-go v1.0.2 h1:l4vs0D+JCakcu5OV0kJ99oEaWJfggSc9jiLpxaWvSzs=
github.com/ojrac/opensimplex-go v1.0.2/go.mod h1:NwbXFFbXcdGgIFdiA7/REME+7n/lOf1TuEbLiZYOWnM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

152
internal/audio/audio.go Normal file
View File

@@ -0,0 +1,152 @@
// Package audio 音频管理器3D 衰减、声源上限、分类音量(场景/音频.md §2§4
//
// 无音频文件阶段用程序化合成音(正弦/噪声包络);播放后端为 beep + otoWindows 纯 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 §3key 为音效键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)
}
}

View File

@@ -0,0 +1,50 @@
package audio
import "testing"
// TestAttenuation 距离衰减公式(音频.md §3
func TestAttenuation(t *testing.T) {
if a := attenuation(0); a != 1 {
t.Fatalf("零距离衰减期望 1实际 %v", a)
}
if a := attenuation(10); a != 0.5 {
t.Fatalf("10 格衰减期望 0.5,实际 %v", a)
}
if a := attenuation(20); a != 0 {
t.Fatalf("最大距离衰减期望 0实际 %v", a)
}
if a := attenuation(30); a != 0 {
t.Fatalf("超距衰减期望 0实际 %v", a)
}
}
// TestBurstStreamer 合成流:有限长度与数据填充。
func TestBurstStreamer(t *testing.T) {
b := sineBurst(440, 0.05)
samples := make([][2]float64, 1024)
total := 0
for {
n, ok := b.Stream(samples)
total += n
if ok {
break
}
}
if total != b.Len() {
t.Fatalf("流长度期望 %d实际 %d", b.Len(), total)
}
if total < 1000 {
t.Fatalf("采样数异常: %d", total)
}
}
// TestManagerSilent 无音频设备环境CI/服务器)下降级为静默、不崩溃。
func TestManagerSilent(t *testing.T) {
m := New() // 可能无设备:必须不 panic
defer m.Close()
m.SetVolumes(1, 1, 1, 1)
m.SetListener(0, 0, 0)
for i := 0; i < 100; i++ {
m.Play("block.stone.break", float64(i), 0, 0) // 池满淘汰也不崩
}
}

69
internal/audio/synth.go Normal file
View File

@@ -0,0 +1,69 @@
// 程序化音效合成:正弦脉冲与噪声脉冲(无音频文件的占位实现,音频.md §6
package audio
import (
"math"
"math/rand"
"time"
"github.com/faiface/beep"
)
// sineBurst 正弦脉冲(频率 Hz、时长秒
func sineBurst(freq float64, dur float64) *burst {
n := int(44100 * dur)
return &burst{n: n, gen: func(t int) float64 {
env := 1 - float64(t)/float64(n) // 线性衰减包络
return env * 0.5 * math.Sin(2*math.Pi*freq*float64(t)/44100)
}}
}
// noiseBurst 噪声脉冲(破坏音效感)。
func noiseBurst(dur float64) *burst {
n := int(44100 * dur)
rng := rand.New(rand.NewSource(42))
return &burst{n: n, gen: func(t int) float64 {
env := 1 - float64(t)/float64(n)
return env * 0.4 * (rng.Float64()*2 - 1)
}}
}
// burst 有限长度合成流。
type burst struct {
n int
pos int
gen func(t int) float64
}
// Stream beep.Streamer填充采样。
func (b *burst) Stream(samples [][2]float64) (int, bool) {
i := 0
for ; i < len(samples) && b.pos < b.n; i++ {
v := b.gen(b.pos)
samples[i][0] = v
samples[i][1] = v
b.pos++
}
return i, b.pos >= b.n
}
// Err 无错误。
func (b *burst) Err() error { return nil }
// Len 时长采样数。
func (b *burst) Len() int { return b.n }
// Position 当前位置。
func (b *burst) Position() int { return b.pos }
// Seek 跳转。
func (b *burst) Seek(p int) error {
if p < 0 || p > b.n {
p = 0
}
b.pos = p
return nil
}
var _ beep.StreamSeeker = (*burst)(nil)
var _ = time.Second

Binary file not shown.