Files
metro-puzzle-jp/tools/gensound/main.go
claude 0c47b0ad00
All checks were successful
release / build-release (push) Successful in 1m36s
M10b: bussar, fler sidospår och överstimulerande UI
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>
2026-10-25 17:33:55 +01:00

301 lines
9.4 KiB
Go
Raw Permalink 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}}
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)
}
}