v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// Package audio provides fire-and-forget sound effect playback.
|
||||
// Placeholder backend: pre-synthesized WAVs spawned via pw-play (PipeWire).
|
||||
// Per-spawn stream connect costs ~tens of ms; replace with a persistent
|
||||
// stream (libpipewire CGO or raylib audio) once latency matters
|
||||
package audio
|
||||
|
||||
// Effect identifies a synthesized sound effect
|
||||
type Effect uint8
|
||||
|
||||
const (
|
||||
EffectNone Effect = iota
|
||||
EffectPickup
|
||||
EffectMagnet
|
||||
EffectShield
|
||||
EffectShieldBreak
|
||||
EffectBoost
|
||||
EffectDeflect
|
||||
EffectMoveRow
|
||||
EffectMoveLane
|
||||
EffectHitWall
|
||||
EffectLevelClear
|
||||
EffectCount
|
||||
)
|
||||
|
||||
// Engine plays effects without blocking the caller
|
||||
type Engine interface {
|
||||
Play(Effect) // EffectNone is a no-op
|
||||
Close()
|
||||
}
|
||||
|
||||
// nullEngine is the silent fallback (unsupported platform, pw-play absent)
|
||||
type nullEngine struct{}
|
||||
|
||||
func (nullEngine) Play(Effect) {}
|
||||
func (nullEngine) Close() {}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package audio
|
||||
|
||||
// NewEngine returns the silent engine on platforms without a backend
|
||||
// (FreeBSD path pending: sndio/OSS)
|
||||
func NewEngine() Engine { return nullEngine{} }
|
||||
@@ -0,0 +1,30 @@
|
||||
package audio
|
||||
|
||||
import "symph/game"
|
||||
|
||||
// MapGameEvent translates engine gameplay events to sound effects
|
||||
func MapGameEvent(e game.Event) Effect {
|
||||
switch e {
|
||||
case game.EventPickup:
|
||||
return EffectPickup
|
||||
case game.EventMagnet:
|
||||
return EffectMagnet
|
||||
case game.EventShield:
|
||||
return EffectShield
|
||||
case game.EventShieldBreak:
|
||||
return EffectShieldBreak
|
||||
case game.EventBoost:
|
||||
return EffectBoost
|
||||
case game.EventDeflect:
|
||||
return EffectDeflect
|
||||
case game.EventMoveRow:
|
||||
return EffectMoveRow
|
||||
case game.EventMoveLane:
|
||||
return EffectMoveLane
|
||||
case game.EventHitWall:
|
||||
return EffectHitWall
|
||||
case game.EventLevelClear:
|
||||
return EffectLevelClear
|
||||
}
|
||||
return EffectNone
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package audio
|
||||
|
||||
// Muter gates playback of a wrapped Engine. The mute state is a host concern:
|
||||
// the engine state machine is unaware and both backends stay untouched
|
||||
type Muter struct {
|
||||
Engine
|
||||
muted bool
|
||||
}
|
||||
|
||||
func NewMuter(e Engine) *Muter { return &Muter{Engine: e} }
|
||||
|
||||
// Play forwards to the wrapped Engine unless gated. In-flight pw-play
|
||||
// processes are not killed; effects are under 300ms
|
||||
func (m *Muter) Play(fx Effect) {
|
||||
if !m.muted {
|
||||
m.Engine.Play(fx)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle flips the gate and reports the new muted state
|
||||
func (m *Muter) Toggle() bool {
|
||||
m.muted = !m.muted
|
||||
return m.muted
|
||||
}
|
||||
|
||||
// Muted reports whether playback is gated
|
||||
func (m *Muter) Muted() bool { return m.muted }
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// pwEngine plays pre-rendered WAVs by spawning pw-play per effect
|
||||
type pwEngine struct {
|
||||
dir string
|
||||
files [EffectCount]string
|
||||
}
|
||||
|
||||
// NewEngine synthesizes effect WAVs into a temp dir. Falls back to a silent
|
||||
// engine when pw-play is unavailable — game remains playable without audio
|
||||
func NewEngine() Engine {
|
||||
if _, err := exec.LookPath("pw-play"); err != nil {
|
||||
return nullEngine{}
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "symph-sfx-")
|
||||
if err != nil {
|
||||
return nullEngine{}
|
||||
}
|
||||
e := &pwEngine{dir: dir}
|
||||
for fx := EffectNone + 1; fx < EffectCount; fx++ {
|
||||
path := filepath.Join(dir, fmt.Sprintf("fx-%d.wav", fx))
|
||||
if err := os.WriteFile(path, wavBytes(synth(effectSegs[fx])), 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return nullEngine{}
|
||||
}
|
||||
e.files[fx] = path
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Play spawns pw-play detached; process reaped asynchronously
|
||||
func (e *pwEngine) Play(fx Effect) {
|
||||
if fx == EffectNone || fx >= EffectCount || e.files[fx] == "" {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("pw-play", "--volume", "0.7", e.files[fx])
|
||||
if err := cmd.Start(); err == nil {
|
||||
go cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *pwEngine) Close() {
|
||||
os.RemoveAll(e.dir)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
const sampleRate = 48000 // PipeWire native rate
|
||||
|
||||
// seg is one tone segment: linear frequency sweep with pluck envelope
|
||||
type seg struct {
|
||||
f0, f1 float64 // Hz
|
||||
dur float64 // seconds
|
||||
amp float64 // 0..1
|
||||
square bool
|
||||
}
|
||||
|
||||
// effectSegs defines each effect's tone sequence — the tuning surface
|
||||
var effectSegs = [EffectCount][]seg{
|
||||
EffectPickup: {{988, 988, 0.055, 0.5, false}, {1319, 1319, 0.11, 0.5, false}}, // B5→E6 coin
|
||||
EffectMagnet: {{523, 1319, 0.10, 0.45, false}, {784, 1976, 0.16, 0.4, false}}, // rising double sweep — reads as a suction, distinct from the coin
|
||||
EffectShield: {{659, 988, 0.07, 0.45, false}, {1319, 1319, 0.16, 0.40, false}}, // chime up into a held tone — armor
|
||||
EffectShieldBreak: {{1200, 300, 0.06, 0.50, true}, {700, 180, 0.14, 0.45, true}}, // square shatter; brighter and shorter than the death crunch
|
||||
EffectBoost: {{392, 1568, 0.16, 0.50, false}, {1568, 1568, 0.10, 0.35, false}}, // fast sweep up — tempo kick
|
||||
EffectDeflect: {{1200, 1600, 0.025, 0.20, true}, {1600, 900, 0.035, 0.15, true}}, // fires once per walled chord traversed under a Boost — short, dry, low
|
||||
EffectMoveRow: {{300, 620, 0.07, 0.35, false}}, // whoosh up
|
||||
EffectMoveLane: {{440, 470, 0.04, 0.28, false}}, // tick
|
||||
EffectHitWall: {{140, 50, 0.30, 0.6, true}}, // crunch drop
|
||||
EffectLevelClear: { // C-major arpeggio
|
||||
{523, 523, 0.09, 0.45, false},
|
||||
{659, 659, 0.09, 0.45, false},
|
||||
{784, 784, 0.09, 0.45, false},
|
||||
{1047, 1047, 0.18, 0.45, false},
|
||||
},
|
||||
}
|
||||
|
||||
// synth renders segments to 16-bit mono PCM
|
||||
func synth(segs []seg) []int16 {
|
||||
var out []int16
|
||||
for _, s := range segs {
|
||||
n := int(s.dur * sampleRate)
|
||||
phase := 0.0
|
||||
for i := range n {
|
||||
t := float64(i) / float64(n)
|
||||
f := s.f0 + (s.f1-s.f0)*t
|
||||
phase += 2 * math.Pi * f / sampleRate
|
||||
v := math.Sin(phase)
|
||||
if s.square {
|
||||
if v >= 0 {
|
||||
v = 1
|
||||
} else {
|
||||
v = -1
|
||||
}
|
||||
}
|
||||
env := math.Min(t*24, 1) * math.Exp(-4.2*t) // fast attack, pluck decay
|
||||
out = append(out, int16(v*env*s.amp*32767))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wavBytes wraps PCM in a minimal RIFF/WAVE container (PCM16 mono)
|
||||
func wavBytes(samples []int16) []byte {
|
||||
var b bytes.Buffer
|
||||
dataLen := uint32(len(samples) * 2)
|
||||
b.WriteString("RIFF")
|
||||
binary.Write(&b, binary.LittleEndian, 36+dataLen)
|
||||
b.WriteString("WAVEfmt ")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(16))
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // PCM
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // mono
|
||||
binary.Write(&b, binary.LittleEndian, uint32(sampleRate))
|
||||
binary.Write(&b, binary.LittleEndian, uint32(sampleRate*2)) // byte rate
|
||||
binary.Write(&b, binary.LittleEndian, uint16(2)) // block align
|
||||
binary.Write(&b, binary.LittleEndian, uint16(16)) // bits/sample
|
||||
b.WriteString("data")
|
||||
binary.Write(&b, binary.LittleEndian, dataLen)
|
||||
binary.Write(&b, binary.LittleEndian, samples)
|
||||
return b.Bytes()
|
||||
}
|
||||
Reference in New Issue
Block a user