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
This commit is contained in:
212
sfx-maker/main.go
Normal file
212
sfx-maker/main.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user