Files
pncdsl/asset.audio.go
2026-05-25 22:27:53 +02:00

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