OpenDiablo2/d2core/d2audio/sound_engine.go

270 lines
5.8 KiB
Go

package d2audio
import (
"fmt"
"math/rand"
"strconv"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2asset"
"github.com/OpenDiablo2/OpenDiablo2/d2core/d2records"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2interface"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2util"
)
type envState int
const (
logPrefix = "Sound Engine"
)
const (
envAttack = 0
envSustain = 1
envRelease = 2
envStopped = 3
)
const volMax float64 = 255
const originalFPS float64 = 25
// A Sound that can be started and stopped
type Sound struct {
effect d2interface.SoundEffect
entry *d2records.SoundDetailRecord
volume float64
vTarget float64
vRate float64
state envState
// panning float64 // lets forget about this for now
*d2util.Logger
}
func (s *Sound) update(elapsed float64) {
// attack
if s.state == envAttack {
s.volume += s.vRate * elapsed
if s.volume > s.vTarget {
s.volume = s.vTarget
s.state = envSustain
}
s.effect.SetVolume(s.volume)
}
// release
if s.state == envRelease {
s.volume -= s.vRate * elapsed
if s.volume < 0 {
s.effect.Stop()
s.volume = 0
s.state = envStopped
}
s.effect.SetVolume(s.volume)
}
}
// SetPan sets the stereo pan, range -1 to 1
func (s *Sound) SetPan(pan float64) {
s.effect.SetPan(pan)
}
// Play the sound
func (s *Sound) Play() {
s.Info("starting sound " + s.entry.Handle)
s.effect.Play()
if s.entry.FadeIn != 0 {
s.effect.SetVolume(0)
s.volume = 0
s.state = envAttack
s.vTarget = float64(s.entry.Volume) / volMax
s.vRate = s.vTarget / (float64(s.entry.FadeIn) / originalFPS)
} else {
s.volume = float64(s.entry.Volume) / volMax
s.effect.SetVolume(s.volume)
s.state = envSustain
}
}
// Stop the sound, only required for looping sounds
func (s *Sound) Stop() {
if s.entry.FadeOut != 0 {
s.state = envRelease
s.vTarget = 0
s.vRate = s.volume / (float64(s.entry.FadeOut) / originalFPS)
} else {
s.state = envStopped
s.volume = 0
s.effect.SetVolume(s.volume)
s.effect.Stop()
}
}
// String returns the sound filename
func (s *Sound) String() string {
return s.entry.Handle
}
// SoundEngine provides functions for playing sounds
type SoundEngine struct {
asset *d2asset.AssetManager
provider d2interface.AudioProvider
timer float64
accTime float64
sounds map[*Sound]struct{}
*d2util.Logger
}
// NewSoundEngine creates a new sound engine
func NewSoundEngine(provider d2interface.AudioProvider,
asset *d2asset.AssetManager, l d2util.LogLevel, term d2interface.Terminal) *SoundEngine {
r := SoundEngine{
asset: asset,
provider: provider,
sounds: map[*Sound]struct{}{},
timer: 1,
}
r.Logger = d2util.NewLogger()
r.Logger.SetPrefix(logPrefix)
r.Logger.SetLevel(l)
if err := term.Bind("playsoundid", "plays the sound for a given id", []string{"id"}, r.commandPlaySoundID); err != nil {
r.Error(err.Error())
return nil
}
if err := term.Bind("playsound", "plays the sound for a given handle string", []string{"name"}, r.commandPlaySound); err != nil {
r.Error(err.Error())
return nil
}
if err := term.Bind("activesounds", "list currently active sounds", nil, r.commandActiveSounds); err != nil {
r.Error(err.Error())
return nil
}
if err := term.Bind("killsounds", "kill active sounds", nil, r.commandKillSounds); err != nil {
r.Error(err.Error())
return nil
}
return &r
}
// Advance updates sound engine state, triggering events and envelopes
func (s *SoundEngine) Advance(elapsed float64) {
s.timer -= elapsed
s.accTime += elapsed
if s.timer < 0 {
for sound := range s.sounds {
sound.update(s.accTime)
// Clean up finished non-looping effects
if !sound.effect.IsPlaying() {
delete(s.sounds, sound)
}
// Clean up stopped looping effects
if sound.state == envStopped {
delete(s.sounds, sound)
}
}
s.timer = 0.2
s.accTime = 0
}
}
// UnbindTerminalCommands unbinds commands from the terminal
func (s *SoundEngine) UnbindTerminalCommands(term d2interface.Terminal) error {
return term.Unbind("playsoundid", "playsound", "activesounds", "killsounds")
}
// Reset stop all sounds and reset state
func (s *SoundEngine) Reset() {
for snd := range s.sounds {
snd.effect.Stop()
delete(s.sounds, snd)
}
}
// PlaySoundID plays a sound by sounds.txt index, returning the sound here is kinda ugly
// now we could have a situation where someone holds onto the sound after the sound engine is done with it
// someone needs to be in charge of deciding when to stopping looping sounds though...
func (s *SoundEngine) PlaySoundID(id int) *Sound {
if id == 0 {
return nil
}
entry := s.asset.Records.SelectSoundByIndex(id)
if entry.GroupSize > 0 {
// nolint:gosec // this is client-only, no big deal if rand index isn't securely generated
indexOffset := rand.Intn(entry.GroupSize)
entry = s.asset.Records.SelectSoundByIndex(entry.Index + indexOffset)
}
effect, err := s.provider.LoadSound(entry.FileName, entry.Loop, entry.MusicVol)
if err != nil {
s.Error(err.Error())
return nil
}
snd := Sound{
entry: entry,
effect: effect,
Logger: s.Logger,
}
s.sounds[&snd] = struct{}{}
snd.Play()
return &snd
}
// PlaySoundHandle plays a sound by sounds.txt handle
func (s *SoundEngine) PlaySoundHandle(handle string) *Sound {
sound := s.asset.Records.Sound.Details[handle].Index
return s.PlaySoundID(sound)
}
func (s *SoundEngine) commandPlaySoundID(args []string) error {
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid argument")
}
s.PlaySoundID(id)
return nil
}
func (s *SoundEngine) commandPlaySound(args []string) error {
s.PlaySoundHandle(args[0])
return nil
}
func (s *SoundEngine) commandActiveSounds([]string) error {
for sound := range s.sounds {
s.Info(sound.String())
}
return nil
}
func (s *SoundEngine) commandKillSounds([]string) error {
for sound := range s.sounds {
sound.Stop()
}
return nil
}