M12: ljud, inställningar och release v0.1.0
All checks were successful
release / build-release (push) Successful in 1m35s
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>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
// Genererar placeholder-ljud tills riktiga inspelningar finns:
|
||||
// Genererar spelets ljud – syntetiserade, deterministiska och fria att
|
||||
// använda, tills riktiga inspelningar finns:
|
||||
//
|
||||
// go run ./tools/gensound
|
||||
//
|
||||
// Skriver resources/sound/melody-approach.wav (en kort pentatonisk
|
||||
// avgångsmelodi i japansk stationsstil, spelas när tåget är på väg in) och
|
||||
// resources/sound/door-chime.wav (dörrsignal strax före avgång). 16-bit
|
||||
// mono 44,1 kHz, syntetiserade med enkla vågformer så att allt är
|
||||
// deterministiskt.
|
||||
// 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 (
|
||||
@@ -24,20 +25,54 @@ type note struct {
|
||||
dur float64 // sekunder
|
||||
}
|
||||
|
||||
// Pentatonisk melodi (C-dur-pentatonik), två takter.
|
||||
// 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{
|
||||
{523.25, 0.18}, {587.33, 0.18}, {659.25, 0.18}, {783.99, 0.36},
|
||||
{880.00, 0.18}, {783.99, 0.18}, {659.25, 0.18}, {523.25, 0.36},
|
||||
{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},
|
||||
{659.25, 0.18}, {783.99, 0.18}, {880.00, 0.18}, {1046.50, 0.54},
|
||||
{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{
|
||||
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.14},
|
||||
{1318.51, 0.14}, {1046.50, 0.14}, {1318.51, 0.14}, {1046.50, 0.28},
|
||||
{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 {
|
||||
@@ -63,6 +98,133 @@ func render(notes []note, attack, release float64, overtone float64) []int16 {
|
||||
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 {
|
||||
@@ -83,39 +245,35 @@ func writeWAV(path string, pcm []int16) error {
|
||||
return binary.Write(f, binary.LittleEndian, pcm)
|
||||
}
|
||||
|
||||
// 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 lcg uint32 = 12345
|
||||
next := func() float64 { lcg = lcg*1664525 + 1013904223; return float64(lcg>>8)/float64(1<<24)*2 - 1 }
|
||||
lp := 0.0
|
||||
env, envTarget, envT := 0.3, 0.3, 0.0
|
||||
for i := 0; i < n; i++ {
|
||||
t := float64(i) / rate
|
||||
// ny "stavelse" var 120–250 ms
|
||||
if t >= envT {
|
||||
envTarget = 0.1 + 0.6*math.Abs(next())
|
||||
envT = t + 0.12 + 0.13*math.Abs(next())
|
||||
}
|
||||
env += (envTarget - env) * 0.002
|
||||
lp += (next() - lp) * 0.08 // lågpass ≈ 600 Hz
|
||||
v := lp * env * 0.6
|
||||
// mjuk in-/utfasning för sömlös loop
|
||||
fade := math.Min(1, math.Min(t, seconds-t)/0.2)
|
||||
out[i] = int16(v * fade * 32767)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := filepath.Join(repoRoot(), "resources", "sound")
|
||||
must(os.MkdirAll(out, 0o755))
|
||||
must(writeWAV(filepath.Join(out, "melody-approach.wav"), render(approachMelody, 0.01, 0.06, 0.3)))
|
||||
must(writeWAV(filepath.Join(out, "door-chime.wav"), render(doorChime, 0.005, 0.04, 0.1)))
|
||||
must(writeWAV(filepath.Join(out, "chatter.wav"), chatter(6)))
|
||||
fmt.Println("skrev", filepath.Join(out, "melody-approach.wav"), ", door-chime.wav och chatter.wav")
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user