All checks were successful
release / build-release (push) Successful in 1m36s
Ett tåg kan vara en buss (vehicle: bus): samma tidtabell, faser, bedömning i nätet och ombordstigning, men ett fordon på en väg i stället för vagnar på räls, hållplatsskylt, "1番" på tavlan, tuta vid inrullning och dörrsignal före avgång. Nätet har tre busslinjer från Narita. Nya sidobanor: 001b Flygbussterminalen (biljettbana med tre bussar) och 003b Harajuku (Yamanote åt rätt eller fel håll runt ringen). Overload-flaggan ger en rullande reklamticker och maximalt med affischer (Ueno, Tokyo). Romaji utan makroner (fonten saknar dem). Skärmdumparna som börjar på bankartan går via titelskärmen. Tester, docs och wiki uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
301 lines
9.4 KiB
Go
301 lines
9.4 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}}
|
||
busHorn = []note{{a4, 0.22}, {f4, 0.3}}
|
||
)
|
||
|
||
// 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) }),
|
||
"bus-horn": square(busHorn),
|
||
"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)
|
||
}
|
||
}
|