diff --git a/DONE.md b/DONE.md index 808ba6b..53e1222 100644 --- a/DONE.md +++ b/DONE.md @@ -505,3 +505,14 @@ is in the working tree. Bodies still idle-stand at those spots. - [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer stays put and plays the hat/sign. + +## Audio + +Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server). + + +`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not. + +- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`. +- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without + an audio server). diff --git a/MEMORY.md b/MEMORY.md index f497019..5cb7443 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; CI builds the portable binaries, NSIS installer, and AppImage on the tag push. -- Next work: `TODO.md` audio (crowd bed rising with mood, radio crackle), then accessibility and - scenario content. +- Next work: `TODO.md` accessibility (grayscale-safe escalation cues, HUD font scale in settings), + then scenario content gaps. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. ## Current State @@ -95,6 +95,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o `_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` before building the review. +- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV` + (one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to + -34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a + dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready` + has not run (tests), and callers handle null. - **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`, `sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward diff --git a/README.md b/README.md index d439b84..8c08150 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,7 @@ The central design goal is simple: **All releases:** -> **Latest: v0.2.7** — NPC theater: guests sit on the steps, tourists and influencers raise a -> camera, performers play their sign. +> **Latest: v0.2.8** — procedural crowd ambience driven by crowd mood, and radio crackle. > Releases are built automatically when a `vX.Y.Z` tag is pushed > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline @@ -253,6 +252,9 @@ godot --headless --path . --script res://src/test/smoke_emergency.gd # Body-camera timeline smoke: review → timeline → step through → back to review godot --headless --path . --script res://src/test/smoke_timeline.gd + +# Audio smoke: crowd bed follows mood, radio open/close crackle +godot --headless --path . --script res://src/test/smoke_audio.gd ``` The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. @@ -305,7 +307,7 @@ Implemented so far: - Godot 4.3 project scaffold, input map, procedural world builder - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **127 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses) +- Headless unit tests: **134 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - Control pass: focus cone on NPC heads with a billboarded gold ring and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`) @@ -320,10 +322,11 @@ Implemented so far: - Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations - Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet - NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint +- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) -Not yet implemented: live 3D body-camera replay, ambience audio, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). +Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist diff --git a/TODO.md b/TODO.md index 6da0dd1..c4e6d4e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,18 +1,10 @@ # TODO -Godot 4.3 prototype. Release **v0.2.7** is tagged. Headless suite **127/127**, plus the session, -PLAN-verb, crowd, emergency, timeline, and windowed focus/radial smokes. +Godot 4.3 prototype. Release **v0.2.8** is tagged. Headless suite **134/134**, plus the session, +PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes. Host `REQUIRE_SIGNIN_VIEW` stays deferred. -## Audio - -`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not. - -- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`. -- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without - an audio server). - ## Accessibility - [ ] Escalation colors that still read in grayscale (shape or label, not hue alone). diff --git a/docs/screens/designs/access_inspection.png b/docs/screens/designs/access_inspection.png index 2342910..ef23836 100644 Binary files a/docs/screens/designs/access_inspection.png and b/docs/screens/designs/access_inspection.png differ diff --git a/docs/screens/designs/late_night_avenue.png b/docs/screens/designs/late_night_avenue.png index 9fcc94b..af03f42 100644 Binary files a/docs/screens/designs/late_night_avenue.png and b/docs/screens/designs/late_night_avenue.png differ diff --git a/docs/screens/designs/radio_report.png b/docs/screens/designs/radio_report.png index 8851748..9c5cf0f 100644 Binary files a/docs/screens/designs/radio_report.png and b/docs/screens/designs/radio_report.png differ diff --git a/docs/screens/designs/targeting_npc.png b/docs/screens/designs/targeting_npc.png index c8e25ae..f49043a 100644 Binary files a/docs/screens/designs/targeting_npc.png and b/docs/screens/designs/targeting_npc.png differ diff --git a/project.godot b/project.godot index 1507c64..f31362e 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="Step Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.2.7" +config/version="0.2.8" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/game/audio_feedback.gd b/src/game/audio_feedback.gd index f068c7c..d53f4de 100644 --- a/src/game/audio_feedback.gd +++ b/src/game/audio_feedback.gd @@ -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 diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index c9532ff..2b1a8ff 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -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) diff --git a/src/test/smoke_audio.gd b/src/test/smoke_audio.gd new file mode 100644 index 0000000..e8541bb --- /dev/null +++ b/src/test/smoke_audio.gd @@ -0,0 +1,85 @@ +extends SceneTree +## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood +## events, and that opening/closing the radio triggers crackle cues. +## +## Usage: +## godot --headless --path . --script res://src/test/smoke_audio.gd +## (Headless uses a dummy audio server; the calls must still be safe.) + +const MAX_FRAMES := 900 + +var frame := 0 +var gm: Node = null +var started := false +var step := 0 +var failures: Array[String] = [] + +func _initialize() -> void: + root.size = Vector2i(1280, 720) + root.add_child(load("res://scenes/main.tscn").instantiate()) + +func _process(_delta: float) -> bool: + frame += 1 + if gm == null: + gm = root.find_child("GameManager", true, false) + return false + if not started: + gm.call("_on_venue_selected", "hollywood_building") + started = true + return false + if frame > MAX_FRAMES: + _fail("timeout") + return true + if frame < 120: + return false + match step: + 0: + _check(gm.audio.bed_playing, "the crowd bed is playing during the shift") + var before: float = gm.audio.crowd_level + gm.coordinator.crowd.apply_event("aggressive_behavior") + _check(gm.audio.crowd_level > before, "an aggressive event raises the bed level") + gm.coordinator.crowd.apply_event("emergency_responder") + _check(gm.audio.crowd_level > before, "the responder keeps it up") + step = 1 + 1: + if gm.npcs.is_empty(): + return false + gm.call("_on_guard_target", gm.npcs[0]) + var crackles: int = gm.audio.radio_crackles + gm.call("_open_radio", false) + _check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once") + gm.call("_on_radio_submitted", { + "npc_id": gm.current_incident_npc_id, + "location": "entrance", + "description": "tourist", + "behavior": "standing", + "weapons_seen": false, + "danger_level": 1, + }, false) + _check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once") + gm.coordinator.call("_finish_shift") + _check(not gm.audio.bed_playing, "the bed stops when the shift ends") + step = 2 + 2: + _finish() + return true + return false + +func _check(condition: bool, message: String) -> void: + if condition: + print("ok: " + message) + else: + _fail(message) + +func _fail(message: String) -> void: + failures.append(message) + _finish() + +func _finish() -> void: + if failures.is_empty(): + print("SMOKE OK: crowd bed and radio crackle") + quit(0) + else: + for message in failures: + printerr("SMOKE FAIL: " + message) + quit(1) diff --git a/src/test/tests/test_audio_feedback.gd b/src/test/tests/test_audio_feedback.gd new file mode 100644 index 0000000..ca3630d --- /dev/null +++ b/src/test/tests/test_audio_feedback.gd @@ -0,0 +1,78 @@ +extends RefCounted +## Audio feedback: a looping crowd bed that follows the crowd mood, and radio +## crackle on open/close. All procedural, no audio assets, headless-safe. + +const AudioFeedback := preload("res://src/game/audio_feedback.gd") +const CrowdSystem := preload("res://src/core/crowd_system.gd") + +func test_bed_volume_follows_mood() -> bool: + var quiet: float = AudioFeedback._level_to_db(0.0) + var loud: float = AudioFeedback._level_to_db(1.0) + var mid: float = AudioFeedback._level_to_db(0.5) + var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet") + var b := TestUtils.check(loud > quiet, "panic raises the bed") + var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual") + return a and b and c + +func test_crowd_level_is_clamped() -> bool: + var audio: Node = AudioFeedback.new() + audio.set_crowd_level(-1.0) + var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0") + audio.set_crowd_level(3.0) + var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1") + audio.free() + return a and b + +func test_crowd_stream_loops() -> bool: + var audio: Node = AudioFeedback.new() + var stream: AudioStreamWAV = audio._build_crowd_stream() + var a := TestUtils.check(stream != null, "a crowd stream is generated") + var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops") + var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data") + var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer") + audio.free() + return a and b and c and d + +func test_crowd_bed_is_headless_safe() -> bool: + # No tree, no audio server: none of these may crash. + var audio: Node = AudioFeedback.new() + audio.start_crowd_bed() + var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing") + audio.set_crowd_level(0.78) + var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored") + audio.stop_crowd_bed() + var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag") + audio.free() + return a and b and c + +func test_radio_crackle_methods() -> bool: + var audio: Node = AudioFeedback.new() + audio.play_radio_open() + audio.play_radio_close() + var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle") + var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe") + audio.free() + return a and b + +func test_game_manager_wires_audio() -> bool: + var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd") + var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift") + var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("), + "crowd events drive the bed") + var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end") + var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles") + var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles") + return a and b and c and d and e + +func test_mood_events_reach_the_bed() -> bool: + ## The bed level is fed from the same payload the crowd reactions use. + var crowd := CrowdSystem.new() + var seen: Array = [] + crowd.changed.connect(func(_p): pass) + crowd.apply_event("aggressive_behavior") + seen.append(crowd.mood) + crowd.apply_event("emergency_responder") + seen.append(crowd.mood) + var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival") + crowd = null + return a