Add procedural crowd ambience and radio crackle
- AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV at runtime (one-pole filtered noise plus a 68/103 Hz drone, seeded) and set_crowd_level() maps crowd mood 0..1 to -34..-12 dB, so the street gets louder as it gets agitated or panics. The bed starts with the shift and stops at shift end. - play_radio_open()/play_radio_close() push short filtered-noise bursts; wired to opening the radio panel, submitting a report, cancelling, and closing the panel. - Still asset-free and headless-safe: with a dummy audio server the calls no-op. _free_player() returns null before _ready and callers handle it. Tests/docs - Headless suite 127 -> 134 (test_audio_feedback.gd). - New smoke_audio.gd: bed playing, level follows mood events, radio crackles once per open/close, bed stops at shift end. Verified windowed (real audio server). - README, TODO section moved to DONE, MEMORY. Version 0.2.8.
This commit is contained in:
1 parent
e2c2a0bf4c
commit
294ac17333
13 files changed
+305
-17
No files matched your search
@@ -8,6 +8,11 @@ extends Node
|
||||
const SAMPLE_RATE := 22050.0
|
||||
|
||||
var _players: Array[AudioStreamPlayer] = []
|
||||
var _bed_player: AudioStreamPlayer = null
|
||||
## Observable state so headless tests and the HUD can see what was requested.
|
||||
var crowd_level: float = 0.0
|
||||
var bed_playing := false
|
||||
var radio_crackles := 0
|
||||
|
||||
func _ready() -> void:
|
||||
# Pre-create a few players so overlapping cues don't cut each other off.
|
||||
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
|
||||
var freq := 440.0 - float(level) * 40.0
|
||||
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
|
||||
|
||||
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
|
||||
## again. Volume then follows `set_crowd_level`.
|
||||
func start_crowd_bed() -> void:
|
||||
if _bed_player == null:
|
||||
_bed_player = AudioStreamPlayer.new()
|
||||
_bed_player.name = "CrowdBed"
|
||||
add_child(_bed_player)
|
||||
_bed_player.stream = _build_crowd_stream()
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
_bed_player.play()
|
||||
bed_playing = true
|
||||
|
||||
func stop_crowd_bed() -> void:
|
||||
if _bed_player:
|
||||
_bed_player.stop()
|
||||
bed_playing = false
|
||||
|
||||
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
|
||||
## street is agitated or panicking.
|
||||
func set_crowd_level(mood: float) -> void:
|
||||
crowd_level = clampf(mood, 0.0, 1.0)
|
||||
if _bed_player:
|
||||
_bed_player.volume_db = _level_to_db(crowd_level)
|
||||
|
||||
static func _level_to_db(mood: float) -> float:
|
||||
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
|
||||
|
||||
## Radio open cue: a short burst of filtered crackle.
|
||||
func play_radio_open() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.09, 0.35)
|
||||
|
||||
## Radio close cue: a lower, shorter click.
|
||||
func play_radio_close() -> void:
|
||||
radio_crackles += 1
|
||||
_play_noise(0.06, 0.25, 1400.0)
|
||||
|
||||
## Play a denial/error cue (low buzz).
|
||||
func play_deny() -> void:
|
||||
_play_tone(220.0, 0.20, 0.5)
|
||||
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
|
||||
var sample := sin(TAU * freq * t) * 0.6
|
||||
playback.push_frame(Vector2(sample, sample))
|
||||
|
||||
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
|
||||
func _build_crowd_stream() -> AudioStreamWAV:
|
||||
const SECONDS := 4.0
|
||||
var frames := int(SAMPLE_RATE * SECONDS)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 20261031
|
||||
var smooth := 0.0
|
||||
for i in frames:
|
||||
var t := float(i) / SAMPLE_RATE
|
||||
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
|
||||
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
|
||||
var sample := (smooth * 2.2 + drone) * 0.35
|
||||
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.loop_begin = 0
|
||||
stream.loop_end = frames
|
||||
return stream
|
||||
|
||||
## Short filtered noise burst used for the radio crackle.
|
||||
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
|
||||
var player := _free_player()
|
||||
if player == null:
|
||||
return
|
||||
var frames := int(SAMPLE_RATE * duration)
|
||||
var data := PackedByteArray()
|
||||
data.resize(frames * 2)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 7
|
||||
var smooth := 0.0
|
||||
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
|
||||
for i in frames:
|
||||
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
|
||||
var env := 1.0 - float(i) / float(maxi(frames, 1))
|
||||
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
|
||||
data[i * 2] = v & 0xFF
|
||||
data[i * 2 + 1] = (v >> 8) & 0xFF
|
||||
var stream := AudioStreamWAV.new()
|
||||
stream.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
stream.mix_rate = int(SAMPLE_RATE)
|
||||
stream.stereo = false
|
||||
stream.data = data
|
||||
player.stream = stream
|
||||
player.volume_db = linear_to_db(volume)
|
||||
player.play()
|
||||
|
||||
func _free_player() -> AudioStreamPlayer:
|
||||
if _players.is_empty():
|
||||
return null
|
||||
for player in _players:
|
||||
if not player.playing:
|
||||
return player
|
||||
|
||||
@@ -233,6 +233,9 @@ func _reset_shift_state() -> void:
|
||||
## Begin (or resume) the shift clock and recapture the mouse.
|
||||
func _start_shift() -> void:
|
||||
_shift_running = true
|
||||
if audio:
|
||||
audio.start_crowd_bed()
|
||||
audio.set_crowd_level(coordinator.crowd.mood)
|
||||
coordinator.start()
|
||||
if hud:
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
@@ -322,6 +325,8 @@ func _build_ui() -> void:
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze())
|
||||
shift_review_panel = ShiftReview.new()
|
||||
@@ -566,6 +571,8 @@ func _connect_crowd() -> void:
|
||||
func _on_crowd_changed(payload: Dictionary) -> void:
|
||||
var event_type := str(payload.get("event", ""))
|
||||
var panic := bool(payload.get("panic", false))
|
||||
if audio:
|
||||
audio.set_crowd_level(float(payload.get("mood", 0.0)))
|
||||
# Panic wins: people clear away from the entrance.
|
||||
if panic or event_type == "crowd_panic":
|
||||
_flee_crowd()
|
||||
@@ -609,6 +616,8 @@ func _responder_profile() -> NpcProfile:
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
_shift_running = false
|
||||
if audio:
|
||||
audio.stop_crowd_bed()
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
@@ -801,6 +810,8 @@ func _close_top_modal() -> bool:
|
||||
return true
|
||||
if radio_panel and radio_panel.visible:
|
||||
radio_panel.hide()
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
return true
|
||||
@@ -1126,9 +1137,13 @@ func _open_radio(emergency: bool) -> void:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
if audio:
|
||||
audio.play_radio_open()
|
||||
_refresh_freeze()
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
if audio:
|
||||
audio.play_radio_close()
|
||||
hud.set_action_hint("")
|
||||
_refresh_freeze()
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
|
||||
Reference in new issue
Block a user