Files
metro-puzzle-jp/tools/gensound/main.go
claude 86f8130735
All checks were successful
release / build-release (push) Successful in 1m35s
M12: ljud, inställningar och release v0.1.0
tools/gensound syntetiserar 21 ljud: fyra stationsmelodier i klockklang
(en per station via melody-fältet eller banans id), ankomstjingel,
ding-dong vid inrullning, dörrsignal vid avgång, spärrens pip och
nekande, kiosk, toalett, lärt ord, bekräfta/varning, byte, personal,
vinst, förlust, flygplan, sorl och menymusik. sfx.go spelar effekter
utifrån vad som hänt i sim.Run sedan förra bildrutan. Ljudlagret har
huvudvolym × effekter/musik och byter inte slinga i onödan.
Inställningar (S på bankartan, sparas): volymer, språk sv/en som laddas
om direkt, fönsterskala, fullskärm (F11 överallt) och tangentöversikt.
Fönsterikon från genart. CHANGELOG.md. Tester för ljudfiler, melodival
och inställningar. Docs, wiki och skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 08:31:38 +01:00

299 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Genererar spelets ljud – syntetiserade, deterministiska och fria att
// använda, tills riktiga inspelningar finns:
//
// go run ./tools/gensound
//
// Skriver 16-bit mono 44,1 kHz WAV till resources/sound/: fyra
// stationsmelodier (avgångsmelodier i japansk stationsstil, en per
// station), ankomstjingel, utropssignal, dörrsignal, spärrens pip och
// nekande, kiosk, toalett, lärt ord, bekräfta/avbryt, byte, vinst, förlust,
// personal, flygplan, bakgrundssorl och kartans musikslinga.
package main
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
)
const rate = 44100
type note struct {
freq float64 // Hz, 0 = paus
dur float64 // sekunder
}
// Toner (Hz).
const (
c5, d5, e5, f5, g5, a5, b5 = 523.25, 587.33, 659.25, 698.46, 783.99, 880.00, 987.77
c6, d6, e6, g6, a6 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00
g4, a4, b4, e4, f4 = 392.00, 440.00, 493.88, 329.63, 349.23
)
// Ankomstjingel (C-dur-pentatonik), spelas när tåget är på väg in.
var approachMelody = []note{
{c5, 0.18}, {d5, 0.18}, {e5, 0.18}, {g5, 0.36},
{a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {c5, 0.36},
{0, 0.12},
{e5, 0.18}, {g5, 0.18}, {a5, 0.18}, {c6, 0.54},
}
// Stationsmelodier: fyra olika avgångsmelodier (発車メロディ-stil, egna
// kompositioner), ca 2,5 s, spelas innan dörrarna stängs.
var stationMelodies = map[string][]note{
"a": {{g5, 0.15}, {e5, 0.15}, {g5, 0.15}, {c6, 0.3}, {b5, 0.15}, {g5, 0.15}, {a5, 0.3}, {0, 0.1}, {g5, 0.15}, {e5, 0.15}, {d5, 0.15}, {c5, 0.6}},
"b": {{e5, 0.2}, {g5, 0.2}, {a5, 0.2}, {g5, 0.2}, {e5, 0.4}, {0, 0.1}, {d5, 0.2}, {e5, 0.2}, {g5, 0.2}, {e5, 0.2}, {d5, 0.2}, {c5, 0.6}},
"c": {{c6, 0.12}, {b5, 0.12}, {a5, 0.12}, {g5, 0.24}, {a5, 0.12}, {g5, 0.12}, {e5, 0.24}, {0, 0.08}, {g5, 0.12}, {a5, 0.12}, {c6, 0.12}, {d6, 0.24}, {c6, 0.6}},
"d": {{a5, 0.18}, {c6, 0.18}, {a5, 0.18}, {g5, 0.36}, {e5, 0.18}, {g5, 0.18}, {a5, 0.36}, {0, 0.1}, {c6, 0.18}, {a5, 0.18}, {g5, 0.18}, {e5, 0.18}, {g5, 0.6}},
}
// Dörrsignal: två toner växelvis.
var doorChime = []note{
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.14},
{e6, 0.14}, {c6, 0.14}, {e6, 0.14}, {c6, 0.28},
}
// Utropssignal ("ding-dong") före utrop.
var announce = []note{{e6, 0.25}, {c6, 0.45}}
// Korta effekter.
var (
learnJingle = []note{{c6, 0.09}, {e6, 0.09}, {g6, 0.22}}
confirmBlip = []note{{a5, 0.05}, {c6, 0.07}}
cancelBlip = []note{{e5, 0.06}, {c5, 0.08}}
transferJingle = []note{{g5, 0.12}, {c6, 0.12}, {e6, 0.12}, {g6, 0.3}}
winFanfare = []note{{c5, 0.14}, {e5, 0.14}, {g5, 0.14}, {c6, 0.4}, {0, 0.06}, {g5, 0.12}, {c6, 0.5}}
loseSting = []note{{e5, 0.22}, {d5, 0.22}, {c5, 0.5}}
kioskCoin = []note{{b5, 0.05}, {e6, 0.05}, {0, 0.04}, {g6, 0.12}}
staffTone = []note{{a4, 0.18}, {0, 0.08}, {a4, 0.18}, {0, 0.12}, {e5, 0.3}}
gateBeep = []note{{a6, 0.09}}
gateDeny = []note{{e4, 0.12}, {0, 0.04}, {e4, 0.12}, {0, 0.04}, {e4, 0.14}}
)
// render syntetiserar toner med sinus + överton, attack/release-envelope.
func render(notes []note, attack, release float64, overtone float64) []int16 {
var out []int16
for _, n := range notes {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
if n.freq == 0 {
out = append(out, 0)
continue
}
t := float64(i) / rate
env := 1.0
if t < attack {
env = t / attack
}
if rem := n.dur - t; rem < release {
env *= rem / release
}
v := math.Sin(2*math.Pi*n.freq*t) + overtone*math.Sin(2*math.Pi*2*n.freq*t)
v = v / (1 + overtone) * env * 0.5
out = append(out, int16(v*32767))
}
}
return out
}
// bell syntetiserar klockliknande toner: grundton + två övertoner som dör
// ut snabbare, exponentiell avklingning – stationsmelodiernas klang.
func bell(notes []note) []int16 {
var out []int16
for _, n := range notes {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
if n.freq == 0 {
out = append(out, 0)
continue
}
t := float64(i) / rate
env := math.Exp(-3.5 * t / n.dur)
if t < 0.004 {
env *= t / 0.004
}
if rem := n.dur - t; rem < 0.02 {
env *= rem / 0.02
}
v := math.Sin(2*math.Pi*n.freq*t) +
0.45*math.Exp(-6*t/n.dur)*math.Sin(2*math.Pi*2*n.freq*t) +
0.2*math.Exp(-9*t/n.dur)*math.Sin(2*math.Pi*3*n.freq*t)
out = append(out, int16(v/1.65*env*0.55*32767))
}
}
return out
}
// square syntetiserar en råare, fyrkantsliknande ton (spärrens nekande).
func square(notes []note) []int16 {
var out []int16
for _, n := range notes {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
if n.freq == 0 {
out = append(out, 0)
continue
}
t := float64(i) / rate
v := 0.0
for h := 1.0; h <= 7; h += 2 { // udda övertoner
v += math.Sin(2*math.Pi*n.freq*h*t) / h
}
env := 1.0
if rem := n.dur - t; rem < 0.02 {
env = rem / 0.02
}
out = append(out, int16(v/1.6*env*0.35*32767))
}
}
return out
}
// lcg är en deterministisk brusgenerator.
type lcg uint32
func (l *lcg) next() float64 {
*l = *l*1664525 + 1013904223
return float64(*l>>8)/float64(1<<24)*2 - 1
}
// noiseSweep är filtrerat brus med envelope och filtersvep: toalettspolning
// (fallande) och flygplan (stigande och fallande).
func noiseSweep(seconds, lpFrom, lpTo float64, env func(t float64) float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var r lcg = 777
lp := 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
f := t / seconds
k := lpFrom + (lpTo-lpFrom)*f // filterkoefficient (0..1): högre = ljusare
lp += (r.next() - lp) * k
out[i] = int16(lp * env(t) * 0.7 * 32767)
}
return out
}
// chatter syntetiserar sex sekunder loopbart stationssorl: lågpassat brus
// med stavelseliknande amplitudmodulation från en deterministisk LCG.
func chatter(seconds float64) []int16 {
n := int(seconds * rate)
out := make([]int16, n)
var r lcg = 12345
lp := 0.0
env, envTarget, envT := 0.3, 0.3, 0.0
for i := 0; i < n; i++ {
t := float64(i) / rate
if t >= envT { // ny "stavelse" var 120–250 ms
envTarget = 0.1 + 0.6*math.Abs(r.next())
envT = t + 0.12 + 0.13*math.Abs(r.next())
}
env += (envTarget - env) * 0.002
lp += (r.next() - lp) * 0.08 // lågpass ≈ 600 Hz
v := lp * env * 0.6
fade := math.Min(1, math.Min(t, seconds-t)/0.2) // sömlös loop
out[i] = int16(v * fade * 32767)
}
return out
}
// mapMusic är kartans musikslinga: ett långsamt arpeggio i C-dur-pentatonik
// över två ackord, mjuka toner, loopbar (8 s).
func mapMusic() []int16 {
pattern := []note{
{c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {a5, 0.5}, {g5, 0.5}, {e5, 0.5}, {d5, 0.5}, {e5, 0.5},
{a4, 0.5}, {e5, 0.5}, {c5, 0.5}, {g5, 0.5}, {e5, 0.5}, {c5, 0.5}, {d5, 0.5}, {g4, 0.5},
}
var out []int16
for _, n := range pattern {
samples := int(n.dur * rate)
for i := 0; i < samples; i++ {
t := float64(i) / rate
env := math.Exp(-2.2*t/n.dur) * math.Min(1, t/0.01)
v := math.Sin(2*math.Pi*n.freq*t) + 0.3*math.Sin(2*math.Pi*2*n.freq*t) + 0.15*math.Sin(2*math.Pi*n.freq/2*t)
out = append(out, int16(v/1.45*env*0.3*32767))
}
}
// låt tonerna ringa in i varandra: mjuk in-/utfasning i kanterna för loopen
for i := 0; i < 400; i++ {
f := float64(i) / 400
out[i] = int16(float64(out[i]) * f)
out[len(out)-1-i] = int16(float64(out[len(out)-1-i]) * f)
}
return out
}
func writeWAV(path string, pcm []int16) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
dataLen := uint32(len(pcm) * 2)
hdr := []any{
[]byte("RIFF"), uint32(36 + dataLen), []byte("WAVE"),
[]byte("fmt "), uint32(16), uint16(1), uint16(1), uint32(rate), uint32(rate * 2), uint16(2), uint16(16),
[]byte("data"), dataLen,
}
for _, h := range hdr {
if err := binary.Write(f, binary.LittleEndian, h); err != nil {
return err
}
}
return binary.Write(f, binary.LittleEndian, pcm)
}
func main() {
out := filepath.Join(repoRoot(), "resources", "sound")
must(os.MkdirAll(out, 0o755))
clips := map[string][]int16{
"melody-approach": render(approachMelody, 0.01, 0.06, 0.3),
"door-chime": render(doorChime, 0.005, 0.04, 0.1),
"announce": bell(announce),
"gate-beep": render(gateBeep, 0.003, 0.02, 0.0),
"gate-deny": square(gateDeny),
"kiosk": render(kioskCoin, 0.003, 0.03, 0.5),
"toilet": noiseSweep(1.1, 0.35, 0.05, func(t float64) float64 { return math.Min(1, t/0.05) * math.Max(0, 1-t/1.1) }),
"learn": bell(learnJingle),
"confirm": render(confirmBlip, 0.003, 0.03, 0.2),
"cancel": render(cancelBlip, 0.003, 0.03, 0.2),
"transfer": bell(transferJingle),
"win": render(winFanfare, 0.005, 0.05, 0.4),
"lose": render(loseSting, 0.01, 0.08, 0.2),
"staff": square(staffTone),
"plane": noiseSweep(3.0, 0.03, 0.4, func(t float64) float64 { return math.Sin(math.Pi * t / 3.0) }),
"chatter": chatter(6),
"music-map": mapMusic(),
}
for id, m := range stationMelodies {
clips["melody-"+id] = bell(m)
}
for name, pcm := range clips {
must(writeWAV(filepath.Join(out, name+".wav"), pcm))
}
fmt.Printf("skrev %d ljud till %s\n", len(clips), out)
}
func repoRoot() string {
dir, _ := os.Getwd()
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
must(fmt.Errorf("hittade ingen go.mod"))
}
dir = parent
}
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "gensound:", err)
os.Exit(1)
}
}