Files
agent-tools/sfx-maker/main.go
claude e7ac31b5c8 sfx-maker: sfxc - sfxr-style .sfx text presets -> 16-bit WAV synthesis
- waves: square (duty), saw, sine, triangle, pitched noise (seeded, deterministic)
- envelope attack/sustain/decay, freq slide, vibrato, arpeggio jump,
  one-pole low/high-pass filters, clamped output
- presets blip/coin/explosion/hurt/jump/laser/powerup with seeded variants;
  preset writes editable .sfx or renders .wav directly
- go tests: parse/validate, determinism, per-preset RMS, WAV header roundtrip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:08:04 +02:00

213 lines
5.6 KiB
Go

// sfxc synthesizes retro game sound effects from .sfx text files
// (sfxr-style parameters) and writes 16-bit mono WAV.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/sfx-maker/sfx"
)
var version = "dev"
const usage = `sfxc - sound effect maker for agents
Usage:
sfxc build <file.sfx> [-o out.wav] synthesize a .sfx file to WAV
sfxc preset <name> [-o out.sfx|out.wav] [--seed n]
write a preset (editable .sfx text,
or straight to .wav)
sfxc info <file.sfx> validate + print parameters
sfxc version
Presets: blip, coin, explosion, hurt, jump, laser, powerup
--seed 0 (default) is the canonical sound; other seeds give variants.
The .sfx format (all keys optional, '#' comments):
sfx: jump name
wave: square square | saw | sine | triangle | noise
volume: 0.7 0..1
attack: 0.01 seconds: fade in
sustain: 0.08 hold
decay: 0.18 fade out
freq: 330 start pitch, Hz
freq-slide: 900 Hz per second (negative = falling)
duty: 0.5 square pulse width 0.05..0.95
vibrato-depth: 25 Hz
vibrato-rate: 9 Hz
arpeggio: 1.335 pitch multiplier that kicks in at...
arpeggio-time: 0.06 ...this many seconds
lowpass: 2200 filter cutoff Hz
highpass: 300 filter cutoff Hz
sample-rate: 44100
seed: 1 noise randomness (deterministic)
Play the result with e.g.: mpv out.wav or aplay out.wav
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "preset":
cmdPreset(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("sfxc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'sfxc help'", os.Args[1])
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if !strings.Contains(name, "=") {
f := fs.Lookup(name)
isBool := false
if f != nil {
if bv, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bv.IsBoolFlag() {
isBool = true
}
}
if !isBool && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
} else {
pos = append(pos, a)
}
}
fs.Parse(append(flags, pos...))
}
func writeWAVFile(path string, p *sfx.Params) error {
samples := sfx.Render(p)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
if err := sfx.WriteWAV(f, samples, p.SampleRate); err != nil {
return err
}
return f.Close()
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output .wav (default: input name with .wav)")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("build takes exactly one .sfx file")
}
p, err := sfx.ParseFile(fs.Arg(0))
if err != nil {
die("%v", err)
}
path := *out
if path == "" {
path = strings.TrimSuffix(fs.Arg(0), filepath.Ext(fs.Arg(0))) + ".wav"
}
if err := writeWAVFile(path, p); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s, %.2fs, %d Hz)\n", path, p.Wave, p.Duration(), p.SampleRate)
}
func cmdPreset(args []string) {
fs := flag.NewFlagSet("preset", flag.ExitOnError)
out := fs.String("o", "", "output file: .sfx (editable text) or .wav (rendered)")
seed := fs.Int64("seed", 0, "0 = canonical, other values = variants")
parseInterspersed(fs, args)
if fs.NArg() != 1 {
die("preset takes exactly one preset name (%s)", sfx.PresetNames())
}
name := fs.Arg(0)
p, err := sfx.Preset(name, *seed)
if err != nil {
die("%v", err)
}
path := *out
if path == "" {
path = name + ".sfx"
}
switch strings.ToLower(filepath.Ext(path)) {
case ".wav":
if err := writeWAVFile(path, p); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s preset, seed %d, %.2fs)\n", path, name, *seed, p.Duration())
case ".sfx":
f, err := os.Create(path)
if err != nil {
die("%v", err)
}
defer f.Close()
comment := fmt.Sprintf("%s preset (seed %d) — edit freely, then: sfxc build %s", name, *seed, path)
if err := p.Write(f, comment); err != nil {
die("%v", err)
}
fmt.Printf("%s (%s preset, seed %d — edit then 'sfxc build')\n", path, name, *seed)
default:
die("-o must end in .sfx or .wav")
}
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .sfx file")
}
p, err := sfx.ParseFile(args[0])
if err != nil {
die("%v", err)
}
fmt.Printf("sfx: %s\n", p.Name)
fmt.Printf("wave: %s\n", p.Wave)
fmt.Printf("duration: %.3fs (attack %.3g + sustain %.3g + decay %.3g)\n",
p.Duration(), p.Attack, p.Sustain, p.Decay)
if p.Wave != "noise" {
fmt.Printf("freq: %g Hz", p.Freq)
if p.FreqSlide != 0 {
fmt.Printf(" (slide %+g Hz/s)", p.FreqSlide)
}
fmt.Println()
}
if p.ArpFactor != 0 {
fmt.Printf("arpeggio: x%g at %gs\n", p.ArpFactor, p.ArpTime)
}
if p.VibratoDepth > 0 {
fmt.Printf("vibrato: ±%g Hz at %g Hz\n", p.VibratoDepth, p.VibratoRate)
}
if p.LowPass > 0 {
fmt.Printf("lowpass: %g Hz\n", p.LowPass)
}
if p.HighPass > 0 {
fmt.Printf("highpass: %g Hz\n", p.HighPass)
}
fmt.Printf("volume: %g\nsamplerate:%d\n", p.Volume, p.SampleRate)
fmt.Println("valid: yes")
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, "sfxc: "+format+"\n", a...)
os.Exit(1)
}