ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 21m50s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m46s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 17m42s
459 lines
11 KiB
Go
459 lines
11 KiB
Go
package audio
|
|
|
|
import (
|
|
"errors"
|
|
"slices"
|
|
"strconv"
|
|
|
|
"aego/asset"
|
|
_ "aego/audio/codec/vorbis"
|
|
_ "aego/audio/codec/wav"
|
|
"aego/audio/mix"
|
|
"aego/audio/pump"
|
|
"aego/core/ids"
|
|
"aego/core/log"
|
|
"aego/data"
|
|
"aego/engine"
|
|
"aego/engine/module"
|
|
"aego/engine/settings"
|
|
"aego/platform"
|
|
"aego/platform/headless"
|
|
"aego/schema"
|
|
"aego/script/bind"
|
|
)
|
|
|
|
const (
|
|
ID = "audio"
|
|
MixerID = ID + ".mixer"
|
|
KeyBufferSize = ID + ".bufferSize"
|
|
KeySampleRate = ID + ".sampleRate"
|
|
KeyEnabled = ID + ".enabled"
|
|
SystemUpdate = ID + ".update"
|
|
MasterBus = "master"
|
|
Voices = 64
|
|
Block = 512
|
|
DefaultFrames = 512
|
|
)
|
|
|
|
var (
|
|
Buses = []string{"master", "music", "sfx", "ui", "ambience", "voice"}
|
|
titles = []string{"Master", "Music", "Effects", "Interface", "Ambience", "Voice"}
|
|
BufferSizes = []int{256, 512, 1024, 2048}
|
|
)
|
|
|
|
func VolumeKey(bus string) string {
|
|
return ID + ".bus." + bus + ".volume"
|
|
}
|
|
|
|
func MuteKey(bus string) string {
|
|
return ID + ".bus." + bus + ".mute"
|
|
}
|
|
|
|
func BusTitle(i int) string {
|
|
if i < 0 || i >= len(titles) {
|
|
return ""
|
|
}
|
|
return titles[i]
|
|
}
|
|
|
|
func BusDescs() []mix.BusDesc {
|
|
out := make([]mix.BusDesc, len(Buses))
|
|
for i, b := range Buses {
|
|
out[i] = mix.BusDesc{ID: b}
|
|
if i > 0 {
|
|
out[i].Parent = MasterBus
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type PlayOptions struct {
|
|
Gain float32
|
|
Pan float32
|
|
Pitch float32
|
|
Priority int32
|
|
Loop bool
|
|
FadeIn float32
|
|
}
|
|
|
|
type EventCounts struct {
|
|
Finished uint64
|
|
Stolen uint64
|
|
Rejected uint64
|
|
Starved uint64
|
|
}
|
|
|
|
type Module struct {
|
|
st *settings.Store
|
|
vol []settings.Key
|
|
mute []settings.Key
|
|
bufKey settings.Key
|
|
rateKey settings.Key
|
|
enabled settings.Key
|
|
eng *engine.Engine
|
|
log *log.ChannelLogger
|
|
mixer *mix.Mixer
|
|
pump *pump.Pump
|
|
out platform.AudioOutput
|
|
spec platform.AudioSpec
|
|
requested platform.AudioSpec
|
|
manual bool
|
|
listeners []func(mix.Event)
|
|
counts EventCounts
|
|
started module.Signal0
|
|
finished module.Signal1[bool]
|
|
worlds []*Service
|
|
session [busCount]float32
|
|
smute [busCount]bool
|
|
clips map[clipCacheKey]*Clip
|
|
warned map[ids.AssetID]bool
|
|
preview mix.VoiceID
|
|
previewCancel chan struct{}
|
|
solo [busCount]bool
|
|
previewID ids.AssetID
|
|
}
|
|
|
|
func New() *Module {
|
|
m := &Module{}
|
|
for i := range m.session {
|
|
m.session[i] = 1
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (*Module) IconDir() string {
|
|
return "modules/audio/res/icons/src"
|
|
}
|
|
|
|
func (*Module) Info() module.Info {
|
|
return module.Info{ID: ID, Version: engine.Version, Builtin: true, DisplayName: "Audio", DocsOrder: 130, DocsIcon: "cat.audio",
|
|
Codes: []string{CodeFormat, CodeLoop, CodeNoClip, CodeNoListener, CodeListeners, CodeEvent}}
|
|
}
|
|
|
|
func choice(name, label string, v int) settings.Choice {
|
|
return settings.Choice{Name: name, Label: label, Value: schema.Enum(int64(v))}
|
|
}
|
|
|
|
func (m *Module) Register(r *module.Registry) error {
|
|
both := settings.User | settings.Project
|
|
r.Setting(settings.Desc{
|
|
ID: KeyEnabled, Kind: schema.KindBool, Default: schema.Bool(true), Title: "Sound",
|
|
Doc: "Turns all sound off without closing the audio output.",
|
|
Category: settings.CategoryAudio, Order: -10, Flags: settings.User, Apply: m.enabledApply,
|
|
})
|
|
for i, b := range Buses {
|
|
apply := m.busApply(i)
|
|
r.Setting(settings.Desc{
|
|
ID: VolumeKey(b), Kind: schema.KindFloat64, Default: schema.Float64(1), Title: titles[i] + " Volume",
|
|
Category: settings.CategoryAudio, Order: i * 10, Flags: both, Min: 0, Max: 1, HasMin: true, HasMax: true, Step: 0.05,
|
|
Apply: apply,
|
|
})
|
|
r.Setting(settings.Desc{
|
|
ID: MuteKey(b), Kind: schema.KindBool, Title: "Mute " + titles[i],
|
|
Category: settings.CategoryAudio, Order: i*10 + 1, Flags: both, Apply: apply,
|
|
})
|
|
}
|
|
sizes := make([]settings.Choice, len(BufferSizes))
|
|
for i, n := range BufferSizes {
|
|
sizes[i] = choice("frames"+strconv.Itoa(n), strconv.Itoa(n), n)
|
|
}
|
|
r.Setting(settings.Desc{
|
|
ID: KeyBufferSize, Kind: schema.KindEnum, Default: schema.Enum(DefaultFrames), Title: "Audio Buffer", Unit: "frames",
|
|
Doc: "Frames per audio block. Smaller blocks lower the latency, larger ones survive slow frames.",
|
|
Category: settings.CategoryAudio, Order: 100, Flags: settings.User, Choices: sizes, Apply: m.deviceApply,
|
|
})
|
|
r.Setting(settings.Desc{
|
|
ID: KeySampleRate, Kind: schema.KindEnum, Default: schema.Enum(0), Title: "Sample Rate", Unit: "Hz",
|
|
Doc: "Auto uses the rate of the output device.",
|
|
Category: settings.CategoryAudio, Order: 110, Flags: settings.User,
|
|
Choices: []settings.Choice{choice("auto", "Auto", 0), choice("hz44100", "44100", 44100), choice("hz48000", "48000", 48000)},
|
|
Apply: m.deviceApply,
|
|
})
|
|
if err := m.registerTypes(r); err != nil {
|
|
return err
|
|
}
|
|
r.Validator(string(asset.ArtAudio), checkClip)
|
|
registerDev(r)
|
|
registerDocs(r)
|
|
r.System(engine.SystemDesc{ID: SystemUpdate, Phase: engine.PhaseFrameBegin, Scope: engine.ScopeFrame, System: updateSystem{m: m}})
|
|
r.System(engine.SystemDesc{ID: SystemReady, Phase: engine.PhasePreUpdate, Scope: engine.ScopePlay, System: readySystem{}})
|
|
r.System(engine.SystemDesc{ID: SystemWorld, Phase: engine.PhasePostUpdate, Scope: engine.ScopePlay, Pause: engine.PauseAlways, System: worldSystem{}})
|
|
if b := r.Bindings(); b != nil {
|
|
if err := errors.Join(
|
|
bind.AssetRefType[ClipRef](b, string(asset.ArtAudio)),
|
|
bind.AssetRefType[data.Ref[Event]](b, bind.DataPrefix+"AudioEvent"),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
installScript(r.ScriptModule(ScriptModule), m)
|
|
if err := bindEvent(b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) busApply(i int) func(schema.Value) error {
|
|
return func(schema.Value) error {
|
|
m.syncBus(i)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m *Module) syncBus(i int) {
|
|
if m.mixer == nil || m.st == nil || i >= len(m.vol) {
|
|
return
|
|
}
|
|
m.mixer.SetBusGain(i, float32(m.st.Float(m.vol[i])))
|
|
m.mixer.SetBusMute(i, m.st.Bool(m.mute[i]))
|
|
}
|
|
|
|
func (m *Module) deviceApply(schema.Value) error {
|
|
if m.mixer == nil {
|
|
return nil
|
|
}
|
|
if want := m.wanted(); want != m.requested {
|
|
return m.Reopen(want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) wanted() platform.AudioSpec {
|
|
s := platform.AudioSpec{Channels: 2, Frames: DefaultFrames}
|
|
if m.st == nil {
|
|
return s
|
|
}
|
|
if m.bufKey != 0 {
|
|
if n := int(m.st.Int(m.bufKey)); n > 0 {
|
|
s.Frames = n
|
|
}
|
|
}
|
|
if m.rateKey != 0 {
|
|
s.SampleRate = int(m.st.Int(m.rateKey))
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (m *Module) Init(ctx *module.InitContext) error {
|
|
m.st = ctx.Settings
|
|
if m.st != nil {
|
|
m.vol, m.mute = m.vol[:0], m.mute[:0]
|
|
for _, b := range Buses {
|
|
m.vol = append(m.vol, m.st.Key(VolumeKey(b)))
|
|
m.mute = append(m.mute, m.st.Key(MuteKey(b)))
|
|
}
|
|
m.bufKey, m.rateKey = m.st.Key(KeyBufferSize), m.st.Key(KeySampleRate)
|
|
m.enabled = m.st.Key(KeyEnabled)
|
|
}
|
|
if ctx.Engine == nil {
|
|
return nil
|
|
}
|
|
ctx.Engine.SetService(MixerID, m)
|
|
m.eng = ctx.Engine
|
|
lg := ctx.Log
|
|
if lg == nil {
|
|
lg = ctx.Engine.Log()
|
|
}
|
|
m.log = lg.With(log.Engine)
|
|
want := m.wanted()
|
|
out := m.open(want)
|
|
m.manual = manual(m.eng, out)
|
|
spec := out.Spec()
|
|
mx, err := mix.New(mix.Config{SampleRate: spec.SampleRate, Channels: 2, Voices: Voices, Buses: BusDescs(), Block: Block})
|
|
if err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
m.mixer, m.out, m.spec, m.requested = mx, out, spec, want
|
|
m.pump = pump.New(mx, out, pumpConfig(out, spec))
|
|
for i := range Buses {
|
|
m.syncBus(i)
|
|
}
|
|
m.syncEnabled()
|
|
if !m.manual {
|
|
m.pump.Start()
|
|
}
|
|
m.log.Info("audio output", log.F("rate", spec.SampleRate), log.F("frames", spec.Frames), log.F("channels", spec.Channels))
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) open(want platform.AudioSpec) platform.AudioOutput {
|
|
if a, ok := m.eng.Platform().(platform.Audioer); ok {
|
|
out, err := a.OpenAudio(want)
|
|
if err == nil {
|
|
return out
|
|
}
|
|
m.log.Warn("the audio device is not available, sound is muted", log.F("error", err.Error()))
|
|
}
|
|
return headless.NewAudio(want, 0, m.eng.Platform().Now)
|
|
}
|
|
|
|
// manual fills the mixer from the frame loop when a headless engine drives the frame-clocked output; any other output gets the pump goroutine.
|
|
func manual(e *engine.Engine, out platform.AudioOutput) bool {
|
|
_, clocked := out.(*headless.Audio)
|
|
return e.Config().Headless && clocked
|
|
}
|
|
|
|
func pumpConfig(out platform.AudioOutput, spec platform.AudioSpec) pump.Config {
|
|
target := 3
|
|
if t, ok := out.(interface{ TargetBlocks() int }); ok {
|
|
target = t.TargetBlocks()
|
|
}
|
|
return pump.Config{SampleRate: spec.SampleRate, Channels: spec.Channels, Block: spec.Frames, Target: target}
|
|
}
|
|
|
|
func (m *Module) Reopen(want platform.AudioSpec) error {
|
|
if m.mixer == nil {
|
|
return nil
|
|
}
|
|
m.pump.Stop()
|
|
m.out.Close()
|
|
out := m.open(want)
|
|
spec := out.Spec()
|
|
if spec.SampleRate != m.mixer.SampleRate() {
|
|
m.mixer.SetSampleRate(spec.SampleRate)
|
|
}
|
|
m.out, m.spec, m.requested = out, spec, want
|
|
m.manual = manual(m.eng, out)
|
|
m.pump.SetOutput(out, pumpConfig(out, spec))
|
|
if !m.manual {
|
|
m.pump.Start()
|
|
}
|
|
m.log.Info("audio output reopened", log.F("rate", spec.SampleRate), log.F("frames", spec.Frames))
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) Shutdown() {
|
|
m.endPreview()
|
|
for _, s := range m.worlds {
|
|
s.drop(true, 0)
|
|
}
|
|
m.worlds = nil
|
|
if m.pump != nil {
|
|
m.pump.Close()
|
|
m.pump = nil
|
|
}
|
|
m.mixer, m.out = nil, nil
|
|
}
|
|
|
|
type updateSystem struct {
|
|
m *Module
|
|
}
|
|
|
|
func (s updateSystem) Run(*engine.Context) {
|
|
s.m.update()
|
|
}
|
|
|
|
func (m *Module) update() {
|
|
if m.mixer == nil {
|
|
return
|
|
}
|
|
if m.manual {
|
|
m.pump.Fill()
|
|
}
|
|
q := m.mixer.Events()
|
|
for {
|
|
ev, ok := q.Pop()
|
|
if !ok {
|
|
return
|
|
}
|
|
switch ev.Kind {
|
|
case mix.EvFinished:
|
|
m.counts.Finished++
|
|
case mix.EvStolen:
|
|
m.counts.Stolen++
|
|
case mix.EvRejected:
|
|
m.counts.Rejected++
|
|
case mix.EvStarved:
|
|
m.counts.Starved++
|
|
}
|
|
if ev.Voice == m.preview && ev.Kind != mix.EvStarved {
|
|
m.endPreview()
|
|
}
|
|
for _, s := range m.worlds {
|
|
if s.onEvent(ev) {
|
|
break
|
|
}
|
|
}
|
|
for _, fn := range m.listeners {
|
|
fn(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Module) Play(src *mix.Memory, bus string, o PlayOptions) mix.VoiceID {
|
|
return m.play(src, nil, bus, o)
|
|
}
|
|
|
|
func (m *Module) PlayStream(st *mix.Stream, bus string, o PlayOptions) mix.VoiceID {
|
|
return m.play(nil, st, bus, o)
|
|
}
|
|
|
|
func (m *Module) play(mem *mix.Memory, st *mix.Stream, bus string, o PlayOptions) mix.VoiceID {
|
|
if m.mixer == nil {
|
|
return mix.VoiceID{}
|
|
}
|
|
if bus == "" {
|
|
bus = "sfx"
|
|
}
|
|
bi := slices.Index(Buses, bus)
|
|
if bi < 0 {
|
|
return mix.VoiceID{}
|
|
}
|
|
gain := o.Gain
|
|
if gain == 0 {
|
|
gain = 1
|
|
}
|
|
return m.mixer.Play(mix.PlayParams{
|
|
Memory: mem, Stream: st, Bus: bi, Gain: gain, Pan: o.Pan, Pitch: o.Pitch,
|
|
Priority: o.Priority, Loop: o.Loop, FadeIn: o.FadeIn,
|
|
})
|
|
}
|
|
|
|
func (m *Module) Stop(id mix.VoiceID, fade float32) bool {
|
|
return m.mixer != nil && m.mixer.Stop(id, fade)
|
|
}
|
|
|
|
func (m *Module) OnEvent(fn func(mix.Event)) {
|
|
m.listeners = append(m.listeners, fn)
|
|
}
|
|
|
|
func (m *Module) Mixer() *mix.Mixer {
|
|
return m.mixer
|
|
}
|
|
|
|
func (m *Module) Spec() platform.AudioSpec {
|
|
return m.spec
|
|
}
|
|
|
|
func (m *Module) Counts() EventCounts {
|
|
return m.counts
|
|
}
|
|
|
|
func (m *Module) Stats() pump.Stats {
|
|
if m.pump == nil {
|
|
return pump.Stats{}
|
|
}
|
|
return m.pump.Stats()
|
|
}
|
|
|
|
func (m *Module) Volume(bus string) float32 {
|
|
i := slices.Index(Buses, bus)
|
|
if i < 0 || m.st == nil || len(m.vol) == 0 {
|
|
return 1
|
|
}
|
|
if m.st.Bool(m.mute[0]) || m.st.Bool(m.mute[i]) {
|
|
return 0
|
|
}
|
|
v := m.st.Float(m.vol[i])
|
|
if i > 0 {
|
|
v *= m.st.Float(m.vol[0])
|
|
}
|
|
return float32(v)
|
|
}
|
|
|
|
func ModuleOf(e *engine.Engine) *Module {
|
|
m, _ := engine.Service[*Module](e, MixerID)
|
|
return m
|
|
}
|