All checks were successful
release / build-release (push) Successful in 1m35s
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>
299 lines
9.3 KiB
Go
299 lines
9.3 KiB
Go
// 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)
|
||
}
|
||
}
|