221 lines
5.5 KiB
Go
221 lines
5.5 KiB
Go
package pncdsl
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2/audio"
|
|
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
|
|
"github.com/hajimehoshi/ebiten/v2/audio/vorbis"
|
|
"github.com/hajimehoshi/ebiten/v2/audio/wav"
|
|
)
|
|
|
|
// audioSampleRate is the playback rate of the shared audio context. WAV,
|
|
// OGG and MP3 streams are resampled to this rate on decode.
|
|
const audioSampleRate = 48000
|
|
|
|
// AudioPlayer plays music and one-shot sound effects through Ebiten's
|
|
// audio subsystem. Music is streamed and looped infinitely; sound effects
|
|
// are decoded once, cached as raw PCM, and replayed via
|
|
// NewPlayerFromBytes for low-latency triggering.
|
|
//
|
|
// AudioPlayer is fail-soft by design: missing asset files, decode errors
|
|
// and a missing audio device all degrade to no-ops with a debug log
|
|
// message — the game keeps running. The action constructors
|
|
// (PlayMusic/PlaySound/StopMusic) call through here unchanged.
|
|
type AudioPlayer struct {
|
|
am *AssetManager
|
|
ctx *audio.Context // lazy — created on first real play
|
|
|
|
musicName string
|
|
musicPlayer *audio.Player
|
|
|
|
sfxCache map[string][]byte // resampled PCM, keyed by Asset.Name
|
|
sfxActive []*audio.Player // in-flight one-shots; pruned each PlaySound
|
|
|
|
failed map[string]bool // assets that errored once — don't retry every frame
|
|
}
|
|
|
|
func NewAudioPlayer() *AudioPlayer {
|
|
return &AudioPlayer{
|
|
sfxCache: make(map[string][]byte),
|
|
failed: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// attach wires the audio player to the asset registry. Called from
|
|
// NewGame after AssetManager is constructed.
|
|
func (a *AudioPlayer) attach(am *AssetManager) { a.am = am }
|
|
|
|
func (a *AudioPlayer) ensureContext() *audio.Context {
|
|
if a.ctx != nil {
|
|
return a.ctx
|
|
}
|
|
// audio.NewContext panics if called twice, so check for an existing
|
|
// singleton first — this lets a test or a host app pre-create one.
|
|
if c := audio.CurrentContext(); c != nil {
|
|
a.ctx = c
|
|
return c
|
|
}
|
|
a.ctx = audio.NewContext(audioSampleRate)
|
|
return a.ctx
|
|
}
|
|
|
|
// resolveAsset returns the registered Asset for name, or false if it's
|
|
// unknown, the wrong kind, or already on the failure list.
|
|
func (a *AudioPlayer) resolveAsset(name string) (Asset, bool) {
|
|
if name == "" || a.am == nil || a.failed[name] {
|
|
return Asset{}, false
|
|
}
|
|
asset, ok := a.am.Get(name)
|
|
if !ok || asset.Kind != AssetAudio || asset.Path == "" {
|
|
return Asset{}, false
|
|
}
|
|
return asset, true
|
|
}
|
|
|
|
func (a *AudioPlayer) PlayMusic(name string) {
|
|
if a.musicName == name && a.musicPlayer != nil && a.musicPlayer.IsPlaying() {
|
|
return
|
|
}
|
|
a.stopMusic()
|
|
a.musicName = name
|
|
|
|
asset, ok := a.resolveAsset(name)
|
|
if !ok {
|
|
logf("audio.PlayMusic %q: no asset", name)
|
|
return
|
|
}
|
|
ctx := a.ensureContext()
|
|
stream, length, err := decodeAudioStream(asset.Path, audioSampleRate)
|
|
if err != nil {
|
|
logf("audio.PlayMusic %q: %v", name, err)
|
|
a.failed[name] = true
|
|
return
|
|
}
|
|
loop := audio.NewInfiniteLoop(stream, length)
|
|
p, err := audio.NewPlayer(ctx, loop)
|
|
if err != nil {
|
|
logf("audio.PlayMusic %q: player: %v", name, err)
|
|
a.failed[name] = true
|
|
return
|
|
}
|
|
a.musicPlayer = p
|
|
p.Play()
|
|
}
|
|
|
|
func (a *AudioPlayer) StopMusic() {
|
|
if a.musicName == "" && a.musicPlayer == nil {
|
|
return
|
|
}
|
|
logf("audio.StopMusic (was %q)", a.musicName)
|
|
a.stopMusic()
|
|
a.musicName = ""
|
|
}
|
|
|
|
func (a *AudioPlayer) stopMusic() {
|
|
if a.musicPlayer == nil {
|
|
return
|
|
}
|
|
_ = a.musicPlayer.Close()
|
|
a.musicPlayer = nil
|
|
}
|
|
|
|
func (a *AudioPlayer) PlaySound(name string) {
|
|
asset, ok := a.resolveAsset(name)
|
|
if !ok {
|
|
logf("audio.PlaySound %q: no asset", name)
|
|
return
|
|
}
|
|
ctx := a.ensureContext()
|
|
|
|
pcm, ok := a.sfxCache[name]
|
|
if !ok {
|
|
data, err := decodeAudioBytes(asset.Path, audioSampleRate)
|
|
if err != nil {
|
|
logf("audio.PlaySound %q: %v", name, err)
|
|
a.failed[name] = true
|
|
return
|
|
}
|
|
a.sfxCache[name] = data
|
|
pcm = data
|
|
}
|
|
p := audio.NewPlayerFromBytes(ctx, pcm)
|
|
p.Play()
|
|
a.sfxActive = append(a.sfxActive, p)
|
|
a.pruneSfx()
|
|
}
|
|
|
|
func (a *AudioPlayer) pruneSfx() {
|
|
n := 0
|
|
for _, p := range a.sfxActive {
|
|
if p.IsPlaying() {
|
|
a.sfxActive[n] = p
|
|
n++
|
|
} else {
|
|
_ = p.Close()
|
|
}
|
|
}
|
|
for i := n; i < len(a.sfxActive); i++ {
|
|
a.sfxActive[i] = nil
|
|
}
|
|
a.sfxActive = a.sfxActive[:n]
|
|
}
|
|
|
|
// audioStream is the common interface satisfied by wav/vorbis/mp3 Stream
|
|
// types — io.ReadSeeker plus a known total length for loop bookkeeping.
|
|
type audioStream interface {
|
|
io.ReadSeeker
|
|
Length() int64
|
|
}
|
|
|
|
func decodeAudioStream(path string, sampleRate int) (audioStream, int64, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
r := bytes.NewReader(data)
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".wav":
|
|
s, err := wav.DecodeWithSampleRate(sampleRate, r)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return s, s.Length(), nil
|
|
case ".ogg":
|
|
s, err := vorbis.DecodeWithSampleRate(sampleRate, r)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return s, s.Length(), nil
|
|
case ".mp3":
|
|
s, err := mp3.DecodeWithSampleRate(sampleRate, r)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return s, s.Length(), nil
|
|
default:
|
|
return nil, 0, fmt.Errorf("unsupported audio extension %q", filepath.Ext(path))
|
|
}
|
|
}
|
|
|
|
// decodeAudioBytes decodes a file to raw int16 stereo PCM at the given
|
|
// sample rate. Used for SFX so NewPlayerFromBytes can replay them
|
|
// without re-decoding on every trigger.
|
|
func decodeAudioBytes(path string, sampleRate int) ([]byte, error) {
|
|
s, _, err := decodeAudioStream(path, sampleRate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.ReadAll(s)
|
|
}
|