# Sprint 19 — AudioManager & SFX Design Spec ## Goal Implement an `AudioManager` autoload that provides background music (per floor, with cross-fade) and SFX feedback for all player interactions. Real audio assets sourced from freesound.org (CC0/CC-BY). All 7 SFX events and 4 music tracks wired up by end of sprint. ## Architecture ### AudioManager (Autoload) `scripts/autoload/AudioManager.gd` — Node with three child AudioStreamPlayers: ``` AudioManager (Node) ├── MusicA (AudioStreamPlayer) # Cross-fade ping-pong player A ├── MusicB (AudioStreamPlayer) # Cross-fade ping-pong player B └── SfxPlayer (AudioStreamPlayer) # Single SFX player (no polyphony this sprint) ``` **Startup:** reads `GameState.music_volume` and `GameState.sfx_volume`, applies to players. Connects to `GameState.state_changed`. **`_on_game_state_changed()`:** reads `GameState.current_room`, calls `_derive_floor_from_room()`, compares to `_current_floor`. If floor changed → `play_floor_music(new_floor)`. **`play_floor_music(floor: int) -> void`:** public API. If `floor == _current_floor` → no-op. Otherwise: inactive player loads new stream, tweens volume from 0 → `GameState.music_volume` over 0.8 s; active player tweens volume → 0 over 0.8 s, then stops. Players swap active/inactive roles. **`play_sfx(event: String) -> void`:** looks up event key in `_SFX_MAP: Dictionary`, loads stream, plays on `SfxPlayer`. Volume = `GameState.sfx_volume`. **`set_music_volume(vol: float) -> void`** and **`set_sfx_volume(vol: float) -> void`:** update `GameState` and apply immediately to active players. **`get_current_floor() -> int`:** returns `_current_floor` (-1 if none set). ### Internal State ```gdscript var _current_floor: int = -1 var _active_player: AudioStreamPlayer # points to MusicA or MusicB ``` ### Floor Derivation `_derive_floor_from_room(room: String) -> int` — pure function, no side effects: | Room strings | Floor | |---|---| | `"reception"`, `"giftshop"`, `"restaurant"`, `"emergency"` | `0` | | `"xray"`, `"pharmacy"`, `"lab"`, `"patient_rooms"` | `1` | | `"ultrasound"`, `"delivery_room"`, `"nursery"` | `2` | | `"garden_party"` | `3` | | anything else | `-1` | ## SFX Event Mapping | Event key | Trigger location | When | |---|---|---| | `chest_tap` | `room_chest.gd` | Chest tapped, before spawn | | `item_spawn` | `room_chest.gd` | Item tween starts (fly-out) | | `item_drag_start` | `holdable_item.gd` | `_on_drag_picked_up` | | `item_drop_hand` | `holdable_item.gd` | Item attached to hand slot | | `item_drop_outfit` | `outfit_item.gd` | Outfit applied to character | | `item_return_chest` | `holdable_item.gd` | `_try_return_to_chest` succeeds | | `item_drop_floor` | `holdable_item.gd` | `_on_drag_released` — no target found | All calls: `AudioManager.play_sfx("event_key")` (autoload, one line, no wiring). ## Asset Specification ### Directory Layout ``` assets/audio/music/ floor_0.ogg floor_1.ogg floor_2.ogg floor_3.ogg assets/audio/sfx/ chest_tap.ogg item_spawn.ogg item_drag_start.ogg item_drop_hand.ogg item_drop_outfit.ogg item_return_chest.ogg item_drop_floor.ogg ``` ### Music — freesound.org Search Terms | File | Search terms | Character | |---|---|---| | `floor_0.ogg` | `"children hospital happy loop"` | Heiter, belebte Lobby | | `floor_1.ogg` | `"calm medical ambient loop"` | Ruhig, klinisch | | `floor_2.ogg` | `"nursery lullaby gentle loop"` | Sanft, Wiegenlied | | `floor_3.ogg` | `"garden birds outdoor loop"` | Draußen, Vogelgezwitscher | Requirements: CC0 or CC-BY, loopable (seamless start/end), 30–60 s duration, `.ogg` or convertible. ### SFX — freesound.org Search Terms | File | Search terms | Target duration | |---|---|---| | `chest_tap.ogg` | `"wooden box tap"` | < 0.3 s | | `item_spawn.ogg` | `"soft whoosh pop"` | < 0.5 s | | `item_drag_start.ogg` | `"soft pickup"` | < 0.3 s | | `item_drop_hand.ogg` | `"light click"` | < 0.2 s | | `item_drop_outfit.ogg` | `"fabric swoosh"` | < 0.5 s | | `item_return_chest.ogg` | `"soft click"` | < 0.2 s | | `item_drop_floor.ogg` | `"light thud"` | < 0.3 s | Requirements: CC0 or CC-BY, child-appropriate, no startling sounds. ### SFX Map in Code ```gdscript const _SFX_MAP: Dictionary = { "chest_tap": "res://assets/audio/sfx/chest_tap.ogg", "item_spawn": "res://assets/audio/sfx/item_spawn.ogg", "item_drag_start": "res://assets/audio/sfx/item_drag_start.ogg", "item_drop_hand": "res://assets/audio/sfx/item_drop_hand.ogg", "item_drop_outfit": "res://assets/audio/sfx/item_drop_outfit.ogg", "item_return_chest":"res://assets/audio/sfx/item_return_chest.ogg", "item_drop_floor": "res://assets/audio/sfx/item_drop_floor.ogg", } ``` ### Music Map in Code ```gdscript const _MUSIC_MAP: Dictionary = { 0: "res://assets/audio/music/floor_0.ogg", 1: "res://assets/audio/music/floor_1.ogg", 2: "res://assets/audio/music/floor_2.ogg", 3: "res://assets/audio/music/floor_3.ogg", } ``` ## Integration with Existing Scripts ### room_chest.gd Add two `AudioManager.play_sfx()` calls: 1. In the chest tap handler (before spawn guard): `AudioManager.play_sfx("chest_tap")` 2. In `spawn_items()`, at the start of each item's tween: `AudioManager.play_sfx("item_spawn")` ### holdable_item.gd - `_on_drag_picked_up`: add `AudioManager.play_sfx("item_drag_start")` - `_try_return_to_chest` (on success, before tween): add `AudioManager.play_sfx("item_return_chest")` - `_on_drag_released` (after hand slot attach): add `AudioManager.play_sfx("item_drop_hand")` - `_on_drag_released` (no target branch): add `AudioManager.play_sfx("item_drop_floor")` ### outfit_item.gd - `_on_drag_released` (after `apply_outfit_item` call): add `AudioManager.play_sfx("item_drop_outfit")` ### project.godot Add `AudioManager` to the autoload list: ``` [autoload] AudioManager="*res://scripts/autoload/AudioManager.gd" ``` ## GameState Integration `AudioManager` observes `GameState.state_changed`. No changes to `GameState` are required — `music_volume` and `sfx_volume` are already persisted in v3. `set_music_volume` / `set_sfx_volume` write back to `GameState` so values persist on save. ## Testing **File:** `test/unit/test_audio_manager.gd` Testable (pure logic, no actual audio): - `_derive_floor_from_room()` — all 12 room strings → correct floor int - `_derive_floor_from_room("unknown")` → `-1` - `play_floor_music()` with same floor → `_current_floor` unchanged (no-op) - `get_current_floor()` → returns `-1` before any music played - `play_sfx()` with unknown key → no crash (guard required) Not testable (manual on device): - Actual audio playback - Cross-fade quality and timing - Volume balance SFX vs. music - Loop seamlessness ## Out of Scope - Polyphonic SFX (multiple simultaneous sounds) - Per-room music (12 tracks) — floor-level is sufficient for this sprint - Character voice sounds (Häschen-Schnuffeln, Kätzchen-Miau) — separate sprint - Settings UI for volume sliders — volume API exists, UI in a later sprint - AudioBus setup (Master/Music/SFX buses) — single bus is sufficient for now