class_name AudioFeedback extends Node ## Procedural audio feedback (PLAN.md: Polish and Accessibility). Generates ## short tones for confirmation prompts, radio responses, and escalation ## feedback without requiring audio assets. Headless-safe: in headless mode ## the AudioServer is a dummy and playback is a no-op. 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 var footsteps := 0 var landings := 0 var precision_ticks := 0 func _ready() -> void: # Pre-create a few players so overlapping cues don't cut each other off. for i in 4: var player := AudioStreamPlayer.new() add_child(player) _players.append(player) ## Play a short confirmation beep (single high tone). func play_confirm() -> void: _play_tone(880.0, 0.12, 0.5) ## Play a radio-response cue (two quick tones). func play_radio_response() -> void: _play_tone(660.0, 0.10, 0.5) _play_tone(990.0, 0.10, 0.5, 0.12) ## Play an escalation warning cue (low descending tone). 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) ## Surface-aware footstep: a short filtered burst, lower on asphalt, brighter on ## the stone entrance steps. func play_footstep(surface: String) -> void: footsteps += 1 var cutoff := 2600.0 match surface: "asphalt": cutoff = 1500.0 "stone": cutoff = 3400.0 _: cutoff = 2400.0 _play_noise(0.045, 0.22, cutoff) ## Landing thump after a step or drop. func play_land() -> void: landings += 1 _play_tone(92.0, 0.09, 0.4) ## Quiet tick when the precision-walk modifier engages. func play_precision_tick() -> void: precision_ticks += 1 _play_tone(1180.0, 0.035, 0.22) ## Play a denial/error cue (low buzz). func play_deny() -> void: _play_tone(220.0, 0.20, 0.5) ## Generate a sine-wave tone and play it on a free player. func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0) -> void: var player := _free_player() if player == null: return var stream := AudioStreamGenerator.new() stream.mix_rate = SAMPLE_RATE stream.buffer_length = duration + 0.05 player.stream = stream player.volume_db = linear_to_db(volume) player.play() # Fill the generator buffer with a sine wave. var playback: AudioStreamGeneratorPlayback = player.get_stream_playback() var frames := int(SAMPLE_RATE * duration) for i in frames: var t := float(i) / SAMPLE_RATE 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 return _players[0]