// 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) } }