- 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
213 lines
5.6 KiB
Go
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)
|
|
}
|