v0.1.0 initial commit

This commit is contained in:
2026-07-15 01:28:56 -04:00
commit 874abee663
35 changed files with 3486 additions and 0 deletions
+35
View File
@@ -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() {}
+7
View File
@@ -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{} }
+30
View File
@@ -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
}
+27
View File
@@ -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 }
+53
View File
@@ -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)
}
+81
View File
@@ -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()
}