diff --git a/README.md b/README.md index a0f9ae7..2a0adc7 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,33 @@ Visual check (opens a window, saves a PNG): /Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd -- car=datto_120y mode=crash frames=280 out=shot.png ``` +## Sound + +Every WAV in `assets/sounds/` is synthesized from maths by +[tools/gen_sounds.py](tools/gen_sounds.py) — sines, noise and envelopes, no +recordings, so the whole kit is licence-free by construction and reproducible +from its seed like everything else in this repo. (Run it through Blender: it's +only there for the bundled numpy, no bpy involved.) + +Loop discipline: anything the game loops (engine, skid, boost, siren) is built +from integer-Hz components over exactly 1 s so the seam is mathematically +silent; noise beds get a crossfaded seam. Loop flags are set in code +([sfx.gd](src/sfx.gd)), not per-file import settings, so a regenerated wav never +silently loses its loop. + +Wiring: every car runs a pitch-tracked engine loop (the arcade law: idle burble +at rest, screaming near top speed, a bump while boosting) plus a skid loop off +the slip value; the player adds a boost whoosh. Crashes pick small/big by jolt +strength with glass on a wreck, pursuit cops wail a two-tone siren (derby +brawlers don't), the Crashbreaker booms, the gearbox clunks or bogs, and event +results sting up or down. + +If the kit ever wants sweetening beyond synthesis, use **CC0 sources only** +(kenney.nl, OpenGameArt filtered to CC0) — CC-BY means shipping attribution +forever and NC-licensed audio doesn't belong in this repo at all. + ## Design doc `docs/BurnoutInfinityShitbox.pdf` — research report on the original game. Still to come: -crash deformation (control-hull vertex displacement, equations in the PDF), oncoming-lane -boost, Road Rage mode, sound, real level GLBs from Blender. +oncoming-lane boost, Road Rage mode, a DEM elevation pass (so the Story Bridge deck +actually rises), and hand-picked crossings for the two scenery-crash junctions. diff --git a/assets/sounds/bog.wav b/assets/sounds/bog.wav new file mode 100644 index 0000000..c8aa7ae Binary files /dev/null and b/assets/sounds/bog.wav differ diff --git a/assets/sounds/bog.wav.import b/assets/sounds/bog.wav.import new file mode 100644 index 0000000..c55175d --- /dev/null +++ b/assets/sounds/bog.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://qbn0cilhfqwj" +path="res://.godot/imported/bog.wav-8f1da2786c55aeefbeb106dc868c91db.sample" + +[deps] + +source_file="res://assets/sounds/bog.wav" +dest_files=["res://.godot/imported/bog.wav-8f1da2786c55aeefbeb106dc868c91db.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/boom.wav b/assets/sounds/boom.wav new file mode 100644 index 0000000..825a45a Binary files /dev/null and b/assets/sounds/boom.wav differ diff --git a/assets/sounds/boom.wav.import b/assets/sounds/boom.wav.import new file mode 100644 index 0000000..59f0456 --- /dev/null +++ b/assets/sounds/boom.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dnwpk58ldx1ky" +path="res://.godot/imported/boom.wav-ce80ab5026d9de2164088103d22abe14.sample" + +[deps] + +source_file="res://assets/sounds/boom.wav" +dest_files=["res://.godot/imported/boom.wav-ce80ab5026d9de2164088103d22abe14.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/boost.wav b/assets/sounds/boost.wav new file mode 100644 index 0000000..44c29e1 Binary files /dev/null and b/assets/sounds/boost.wav differ diff --git a/assets/sounds/boost.wav.import b/assets/sounds/boost.wav.import new file mode 100644 index 0000000..a440a15 --- /dev/null +++ b/assets/sounds/boost.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://da0fblx1gdv5u" +path="res://.godot/imported/boost.wav-810a94b056a1e46df7bb29aadebe3719.sample" + +[deps] + +source_file="res://assets/sounds/boost.wav" +dest_files=["res://.godot/imported/boost.wav-810a94b056a1e46df7bb29aadebe3719.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/clunk.wav b/assets/sounds/clunk.wav new file mode 100644 index 0000000..1bd8a6f Binary files /dev/null and b/assets/sounds/clunk.wav differ diff --git a/assets/sounds/clunk.wav.import b/assets/sounds/clunk.wav.import new file mode 100644 index 0000000..b59dd09 --- /dev/null +++ b/assets/sounds/clunk.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bypon8wahkl6m" +path="res://.godot/imported/clunk.wav-39e9719aa8e918e8eeb10576b0be4c5d.sample" + +[deps] + +source_file="res://assets/sounds/clunk.wav" +dest_files=["res://.godot/imported/clunk.wav-39e9719aa8e918e8eeb10576b0be4c5d.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/crash_big.wav b/assets/sounds/crash_big.wav new file mode 100644 index 0000000..e8a5f34 Binary files /dev/null and b/assets/sounds/crash_big.wav differ diff --git a/assets/sounds/crash_big.wav.import b/assets/sounds/crash_big.wav.import new file mode 100644 index 0000000..5e14765 --- /dev/null +++ b/assets/sounds/crash_big.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bcxbcnytwk7pj" +path="res://.godot/imported/crash_big.wav-4b192579a3fd4414c388af2e6de59ed6.sample" + +[deps] + +source_file="res://assets/sounds/crash_big.wav" +dest_files=["res://.godot/imported/crash_big.wav-4b192579a3fd4414c388af2e6de59ed6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/crash_small.wav b/assets/sounds/crash_small.wav new file mode 100644 index 0000000..1d22895 Binary files /dev/null and b/assets/sounds/crash_small.wav differ diff --git a/assets/sounds/crash_small.wav.import b/assets/sounds/crash_small.wav.import new file mode 100644 index 0000000..c823ea3 --- /dev/null +++ b/assets/sounds/crash_small.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://ceeyt53afugxp" +path="res://.godot/imported/crash_small.wav-94e44666608124db8d2651ef7b97a9aa.sample" + +[deps] + +source_file="res://assets/sounds/crash_small.wav" +dest_files=["res://.godot/imported/crash_small.wav-94e44666608124db8d2651ef7b97a9aa.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/engine.wav b/assets/sounds/engine.wav new file mode 100644 index 0000000..8ae1704 Binary files /dev/null and b/assets/sounds/engine.wav differ diff --git a/assets/sounds/engine.wav.import b/assets/sounds/engine.wav.import new file mode 100644 index 0000000..120ed1f --- /dev/null +++ b/assets/sounds/engine.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cybkeltoy7j3a" +path="res://.godot/imported/engine.wav-d6786269ebfd2ada75bdd079c2e17f80.sample" + +[deps] + +source_file="res://assets/sounds/engine.wav" +dest_files=["res://.godot/imported/engine.wav-d6786269ebfd2ada75bdd079c2e17f80.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/glass.wav b/assets/sounds/glass.wav new file mode 100644 index 0000000..54fc400 Binary files /dev/null and b/assets/sounds/glass.wav differ diff --git a/assets/sounds/glass.wav.import b/assets/sounds/glass.wav.import new file mode 100644 index 0000000..da2457e --- /dev/null +++ b/assets/sounds/glass.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cttvhh5nm5j1m" +path="res://.godot/imported/glass.wav-8cd29b0619eb51b1e55ea8a7e038a004.sample" + +[deps] + +source_file="res://assets/sounds/glass.wav" +dest_files=["res://.godot/imported/glass.wav-8cd29b0619eb51b1e55ea8a7e038a004.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/siren.wav b/assets/sounds/siren.wav new file mode 100644 index 0000000..49d8f86 Binary files /dev/null and b/assets/sounds/siren.wav differ diff --git a/assets/sounds/siren.wav.import b/assets/sounds/siren.wav.import new file mode 100644 index 0000000..6bcac5f --- /dev/null +++ b/assets/sounds/siren.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cwrt3oul3vs3m" +path="res://.godot/imported/siren.wav-847d6fbddcbf15b051f8f55cc9265888.sample" + +[deps] + +source_file="res://assets/sounds/siren.wav" +dest_files=["res://.godot/imported/siren.wav-847d6fbddcbf15b051f8f55cc9265888.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/skid.wav b/assets/sounds/skid.wav new file mode 100644 index 0000000..0bd0ff2 Binary files /dev/null and b/assets/sounds/skid.wav differ diff --git a/assets/sounds/skid.wav.import b/assets/sounds/skid.wav.import new file mode 100644 index 0000000..1ba9f30 --- /dev/null +++ b/assets/sounds/skid.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://xhfwat50fi7v" +path="res://.godot/imported/skid.wav-2935abfa7678803791f7dd569a3dc250.sample" + +[deps] + +source_file="res://assets/sounds/skid.wav" +dest_files=["res://.godot/imported/skid.wav-2935abfa7678803791f7dd569a3dc250.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/sting_lose.wav b/assets/sounds/sting_lose.wav new file mode 100644 index 0000000..1589318 Binary files /dev/null and b/assets/sounds/sting_lose.wav differ diff --git a/assets/sounds/sting_lose.wav.import b/assets/sounds/sting_lose.wav.import new file mode 100644 index 0000000..e2a2eb0 --- /dev/null +++ b/assets/sounds/sting_lose.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dw8p7p4q4y413" +path="res://.godot/imported/sting_lose.wav-6123e9e6785ae293e80b5df7e1e06e37.sample" + +[deps] + +source_file="res://assets/sounds/sting_lose.wav" +dest_files=["res://.godot/imported/sting_lose.wav-6123e9e6785ae293e80b5df7e1e06e37.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/assets/sounds/sting_win.wav b/assets/sounds/sting_win.wav new file mode 100644 index 0000000..db18280 Binary files /dev/null and b/assets/sounds/sting_win.wav differ diff --git a/assets/sounds/sting_win.wav.import b/assets/sounds/sting_win.wav.import new file mode 100644 index 0000000..f55da13 --- /dev/null +++ b/assets/sounds/sting_win.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dskukm4ylh7n7" +path="res://.godot/imported/sting_win.wav-99ef8cb7f30234f01743fb8f02166015.sample" + +[deps] + +source_file="res://assets/sounds/sting_win.wav" +dest_files=["res://.godot/imported/sting_win.wav-99ef8cb7f30234f01743fb8f02166015.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/src/car.gd b/src/car.gd index 21d8432..3b1fa4d 100644 --- a/src/car.gd +++ b/src/car.gd @@ -43,6 +43,9 @@ var _prev_lv := Vector3.ZERO var _impact_cd := 0.0 var _parts: Array[Node] = [] var _deform: Deformer = null +var _snd_eng: AudioStreamPlayer3D +var _snd_skid: AudioStreamPlayer3D +var _snd_boost: AudioStreamPlayer3D @onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR] @@ -71,6 +74,13 @@ func _ready() -> void: var bm := BoxMesh.new() bm.size = Vector3(stats.width, 1.0, stats.length) $Body.mesh = bm + # the player's own engine sits front and centre; everyone else's is scenery + _snd_eng = Sfx.looper(self, "engine", -9.0 if is_player else -17.0) + if _snd_eng: + _snd_eng.play() + _snd_skid = Sfx.looper(self, "skid", -7.0 if is_player else -14.0) + if is_player: + _snd_boost = Sfx.looper(self, "boost", -8.0) func _physics_process(delta: float) -> void: if is_player: @@ -123,6 +133,25 @@ func _physics_process(delta: float) -> void: if boosting: boost = maxf(boost - BOOST_DRAIN * delta, 0.0) + if _snd_eng: + # arcade pitch law: idle burble at rest, screaming near top speed + _snd_eng.pitch_scale = clampf(0.55 + linear_velocity.length() / 26.0, 0.55, 2.5) \ + + (0.25 if boosting else 0.0) + _snd_eng.volume_db = (-9.0 if is_player else -17.0) + absf(throttle_in) * 4.0 + if _snd_skid: + var skidding := grounded >= 2 and absf(slip) > 4.0 and linear_velocity.length() > 8.0 + if skidding and not _snd_skid.playing: + _snd_skid.play() + elif not skidding and _snd_skid.playing: + _snd_skid.stop() + if skidding: + _snd_skid.pitch_scale = clampf(0.85 + absf(slip) / 40.0, 0.85, 1.4) + if _snd_boost: + if boosting and not _snd_boost.playing: + _snd_boost.play() + elif not boosting and _snd_boost.playing: + _snd_boost.stop() + var jolt := linear_velocity - _prev_lv _prev_lv = linear_velocity _impact_cd = maxf(_impact_cd - delta, 0.0) @@ -136,6 +165,10 @@ func earn_boost(amount: float) -> void: func _impact(jolt: Vector3) -> void: var dv := jolt.length() crashed.emit(dv) + Sfx.shot(self, "crash_big" if dv > 8.0 else "crash_small", + -2.0, randf_range(0.9, 1.1)) + if dv > WRECK_DV: + Sfx.shot(self, "glass", -4.0) var side := (global_basis.inverse() * -jolt).normalized() if _deform: # no contact manifold from the jolt heuristic, so approximate the strike diff --git a/src/cop.gd b/src/cop.gd index e4b35df..7e7fd3b 100644 --- a/src/cop.gd +++ b/src/cop.gd @@ -9,9 +9,18 @@ extends Car var target: Node3D var power_boost := 1.0 # scales with heat: more wanted = more committed +var siren := true # pursuit cops wail; derby brawlers don't var _stuck := 0.0 var _unstick := 0.0 +var _snd_siren: AudioStreamPlayer3D + +func _ready() -> void: + super() + if siren: + _snd_siren = Sfx.looper(self, "siren", -8.0) + if _snd_siren: + _snd_siren.play() func _physics_process(delta: float) -> void: if target and not wrecked: diff --git a/src/game.gd b/src/game.gd index d947e65..381c336 100644 --- a/src/game.gd +++ b/src/game.gd @@ -203,6 +203,7 @@ func _ready() -> void: var d: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate() d.stats = load(pool[randi() % pool.size()]) d.power_boost = 1.0 + d.siren = false # brawlers, not police # a wreck is an elimination: without this car.gd's 2.2 s _recover # timer stands them back up and the field never empties d.hold_wreck = true @@ -594,6 +595,7 @@ func _derby_down(d: Cop) -> void: derby_kills += 1 car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) car.boost = car.boost_max + Sfx.shot(car, "sting_win", -4.0) _msg("WRECKED 'EM! x%d" % derby_kills, 46) func _derby_tick(delta: float) -> void: @@ -642,10 +644,12 @@ func _drag_tick(delta: float) -> void: if Input.is_action_just_pressed("boost"): if revs >= SHIFT_LO and revs < REDLINE: clean_shifts += 1 + Sfx.shot(car, "clunk", -2.0) _msg("CLEAN SHIFT", 34) else: blown_shifts += 1 bog = 0.9 + Sfx.shot(car, "bog", -2.0) _msg("MISSED IT", 34) gear += 1 revs = 0.28 if gear <= DRAG_GEARS else 1.0 @@ -727,9 +731,11 @@ func _end_pursuit(escaped: bool) -> void: escapes += 1 car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) car.boost = car.boost_max + Sfx.shot(car, "sting_win", -4.0) _msg("LOST 'EM! x%d" % escapes, 52) else: busts += 1 + Sfx.shot(car, "sting_lose", -4.0) _msg("BUSTED", 52) func _pursuit_tick(delta: float) -> void: @@ -831,8 +837,10 @@ func _outrun_end(won: bool, why: String) -> void: outruns_won += 1 car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) # takedown-tier reward car.boost = car.boost_max + Sfx.shot(car, "sting_win", -4.0) _msg("OUTRUN WON! x%d%s" % [outruns_won, tail], 52) else: + Sfx.shot(car, "sting_lose", -4.0) _msg("OUTRUN LOST%s" % tail, 44) func _race_tick() -> void: @@ -894,6 +902,7 @@ func _rival_wrecked(r: Rival) -> void: return car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) car.boost = car.boost_max + Sfx.shot(car, "sting_win", -4.0) _msg("TAKEDOWN!", 56) func _traffic_smashed(impulse: float) -> void: @@ -927,6 +936,7 @@ func _crashbreaker() -> void: else: (b as RigidBody3D).apply_central_impulse(dir * strength * (b as RigidBody3D).mass) car.apply_central_impulse(Vector3.UP * car.mass * 7.0) + Sfx.shot(car, "boom", 0.0) _msg("CRASHBREAKER!", 52) var boom := MeshInstance3D.new() var sm := SphereMesh.new() diff --git a/src/sfx.gd b/src/sfx.gd new file mode 100644 index 0000000..60933e6 --- /dev/null +++ b/src/sfx.gd @@ -0,0 +1,51 @@ +class_name Sfx +## Tiny audio utility over the procedural kit in assets/sounds (tools/gen_sounds.py). +## Loops are flagged here rather than in per-file .import settings so a wav +## regenerated by the tool never silently loses its loop flag. + +const DIR := "res://assets/sounds/%s.wav" +const LOOPS := ["engine", "skid", "boost", "siren"] + +static var _cache := {} + +static func stream(name: String) -> AudioStream: + if _cache.has(name): + return _cache[name] + var path := DIR % name + if not ResourceLoader.exists(path): + return null + var s: AudioStream = load(path) + if s is AudioStreamWAV and name in LOOPS: + var w := s as AudioStreamWAV + w.loop_mode = AudioStreamWAV.LOOP_FORWARD + w.loop_begin = 0 + w.loop_end = w.data.size() / 2 # 16-bit mono: 2 bytes per frame + _cache[name] = s + return s + +static func looper(parent: Node, name: String, db: float) -> AudioStreamPlayer3D: + ## Persistent 3D player for a looped bed (engine, skid, siren). Not playing + ## until the caller decides; returns null when the kit isn't generated yet. + var s := stream(name) + if s == null: + return null + var p := AudioStreamPlayer3D.new() + p.stream = s + p.volume_db = db + p.max_distance = 90.0 + parent.add_child(p) + return p + +static func shot(parent: Node, name: String, db := 0.0, pitch := 1.0) -> void: + ## Fire-and-forget one-shot at the parent's position. + var s := stream(name) + if s == null: + return + var p := AudioStreamPlayer3D.new() + p.stream = s + p.volume_db = db + p.pitch_scale = pitch + p.max_distance = 160.0 + parent.add_child(p) + p.play() + p.finished.connect(p.queue_free) diff --git a/src/sfx.gd.uid b/src/sfx.gd.uid new file mode 100644 index 0000000..528e9ca --- /dev/null +++ b/src/sfx.gd.uid @@ -0,0 +1 @@ +uid://bf8uwanb2pyhb diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py new file mode 100644 index 0000000..6d33b80 --- /dev/null +++ b/tools/gen_sounds.py @@ -0,0 +1,156 @@ +"""Procedural sound kit: every WAV the game plays, synthesized from maths. + +Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/gen_sounds.py + (Blender only for its bundled numpy -- no bpy used) + +Licence-free by construction: sines, noise and envelopes, no recordings. +Deterministic (seeded), so a rebuild is byte-for-byte reproducible. + +Loop discipline: anything the game loops (engine, skid, boost, siren) is built +from integer-Hz components over an exact 1 s so the seam is mathematically +silent; noise beds get a crossfaded seam instead. One-shots just decay to zero. +""" +import math +import os +import wave + +import numpy as np + +SR = 22050 +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "sounds") +rng = np.random.default_rng(1982) + + +def t(dur): + return np.arange(int(SR * dur)) / SR + + +def env_exp(dur, tau): + return np.exp(-t(dur) / tau) + + +def noise(dur): + return rng.standard_normal(int(SR * dur)) + + +def band(x, lo, hi, peaks=()): + """FFT bandpass with optional resonant peaks [(hz, width, gain)].""" + spec = np.fft.rfft(x) + f = np.fft.rfftfreq(len(x), 1 / SR) + mask = ((f >= lo) & (f <= hi)).astype(float) + edge = (hi - lo) * 0.15 + 1.0 + mask = np.convolve(mask, np.hanning(9) / np.hanning(9).sum(), mode="same") + for hz, width, gain in peaks: + mask += gain * np.exp(-((f - hz) / width) ** 2) + return np.fft.irfft(spec * mask, len(x)) + + +def seam(x, fade=0.06): + """Crossfade the tail into the head so a noise bed loops cleanly.""" + n = int(SR * fade) + w = np.linspace(0, 1, n) + x[:n] = x[:n] * w + x[-n:] * (1 - w) + return x[:-n] + + +def norm(x, peak=0.85): + return x / (np.max(np.abs(x)) + 1e-9) * peak + + +def write(name, x): + x16 = (np.clip(x, -1, 1) * 32767).astype("