# Phase 6-2: 3D Audio Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 3D 공간 오디오 — 거리 감쇠와 스테레오 패닝으로 소리의 공간감 표현 **Architecture:** `voltex_audio`에 spatial.rs 추가 (순수 함수), mixing.rs와 audio_system.rs를 확장하여 3D 사운드 지원. voltex_math 의존 추가. **Tech Stack:** Rust, voltex_math (Vec3) **Spec:** `docs/superpowers/specs/2026-03-25-phase6-2-3d-audio.md` --- ## File Structure - `crates/voltex_audio/Cargo.toml` — voltex_math 의존 추가 (Modify) - `crates/voltex_audio/src/spatial.rs` — Listener, SpatialParams, 감쇠/패닝 함수 (Create) - `crates/voltex_audio/src/mixing.rs` — PlayingSound에 spatial 추가, mix_sounds에 listener 파라미터 (Modify) - `crates/voltex_audio/src/audio_system.rs` — Play3d, SetListener 명령 추가 (Modify) - `crates/voltex_audio/src/lib.rs` — spatial 모듈 등록 (Modify) --- ## Task 1: spatial.rs — 감쇠/패닝 순수 함수 **Files:** - Modify: `crates/voltex_audio/Cargo.toml` (add voltex_math dependency) - Create: `crates/voltex_audio/src/spatial.rs` - Modify: `crates/voltex_audio/src/lib.rs` - [ ] **Step 1: Cargo.toml에 voltex_math 의존 추가** ```toml [dependencies] voltex_math.workspace = true ``` - [ ] **Step 2: spatial.rs 작성** ```rust // crates/voltex_audio/src/spatial.rs use voltex_math::Vec3; #[derive(Debug, Clone, Copy)] pub struct Listener { pub position: Vec3, pub forward: Vec3, pub right: Vec3, } impl Default for Listener { fn default() -> Self { Self { position: Vec3::ZERO, forward: Vec3::new(0.0, 0.0, -1.0), // -Z right: Vec3::X, } } } #[derive(Debug, Clone, Copy)] pub struct SpatialParams { pub position: Vec3, pub min_distance: f32, pub max_distance: f32, } impl SpatialParams { pub fn new(position: Vec3, min_distance: f32, max_distance: f32) -> Self { Self { position, min_distance, max_distance } } /// Convenience: create with default min=1.0, max=50.0. pub fn at(position: Vec3) -> Self { Self { position, min_distance: 1.0, max_distance: 50.0 } } } /// Compute volume attenuation based on distance (inverse distance model). /// Returns 1.0 at min_dist or closer, 0.0 at max_dist or farther. pub fn distance_attenuation(distance: f32, min_dist: f32, max_dist: f32) -> f32 { if distance <= min_dist { return 1.0; } if distance >= max_dist { return 0.0; } // Inverse distance: min_dist / distance, clamped to [0, 1] (min_dist / distance).clamp(0.0, 1.0) } /// Compute stereo pan gains (left, right) using equal-power panning. /// Returns (1.0, 1.0) if emitter is at listener position. pub fn stereo_pan(listener: &Listener, emitter_pos: Vec3) -> (f32, f32) { let diff = emitter_pos - listener.position; let dist_sq = diff.length_squared(); if dist_sq < 1e-8 { return (1.0, 1.0); } let direction = diff.normalize(); // pan: -1.0 = full left, 0.0 = center, 1.0 = full right let pan = direction.dot(listener.right).clamp(-1.0, 1.0); // Equal-power panning: angle = pan * PI/4 + PI/4 let angle = pan * std::f32::consts::FRAC_PI_4 + std::f32::consts::FRAC_PI_4; let left = angle.cos(); let right = angle.sin(); (left, right) } /// Convenience: compute all spatial gains at once. /// Returns (attenuation, left_gain, right_gain). pub fn compute_spatial_gains(listener: &Listener, spatial: &SpatialParams) -> (f32, f32, f32) { let diff = spatial.position - listener.position; let distance = diff.length(); let atten = distance_attenuation(distance, spatial.min_distance, spatial.max_distance); let (left, right) = stereo_pan(listener, spatial.position); (atten, left, right) } #[cfg(test)] mod tests { use super::*; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-3 } // distance_attenuation tests #[test] fn test_attenuation_at_min() { assert!(approx(distance_attenuation(0.5, 1.0, 50.0), 1.0)); assert!(approx(distance_attenuation(1.0, 1.0, 50.0), 1.0)); } #[test] fn test_attenuation_at_max() { assert!(approx(distance_attenuation(50.0, 1.0, 50.0), 0.0)); assert!(approx(distance_attenuation(100.0, 1.0, 50.0), 0.0)); } #[test] fn test_attenuation_between() { let a = distance_attenuation(5.0, 1.0, 50.0); assert!(a > 0.0 && a < 1.0); assert!(approx(a, 1.0 / 5.0)); // min_dist / distance = 0.2 } // stereo_pan tests #[test] fn test_pan_right() { let listener = Listener::default(); // Emitter to the right (+X) let (left, right) = stereo_pan(&listener, Vec3::new(5.0, 0.0, 0.0)); assert!(right > left, "right={} should be > left={}", right, left); } #[test] fn test_pan_left() { let listener = Listener::default(); // Emitter to the left (-X) let (left, right) = stereo_pan(&listener, Vec3::new(-5.0, 0.0, 0.0)); assert!(left > right, "left={} should be > right={}", left, right); } #[test] fn test_pan_front() { let listener = Listener::default(); // Emitter directly in front (-Z) let (left, right) = stereo_pan(&listener, Vec3::new(0.0, 0.0, -5.0)); // Should be roughly equal (center pan) assert!((left - right).abs() < 0.1, "left={} right={}", left, right); } #[test] fn test_pan_same_position() { let listener = Listener::default(); let (left, right) = stereo_pan(&listener, Vec3::ZERO); assert!(approx(left, 1.0)); assert!(approx(right, 1.0)); } // compute_spatial_gains test #[test] fn test_compute_spatial_gains() { let listener = Listener::default(); let spatial = SpatialParams::new(Vec3::new(5.0, 0.0, 0.0), 1.0, 50.0); let (atten, left, right) = compute_spatial_gains(&listener, &spatial); assert!(approx(atten, 0.2)); // 1.0 / 5.0 assert!(right > left); // right side } } ``` - [ ] **Step 3: lib.rs에 spatial 모듈 등록** ```rust pub mod spatial; pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains}; ``` - [ ] **Step 4: 테스트 실행** Run: `cargo test -p voltex_audio` Expected: 기존 15 + 8 = 23 PASS - [ ] **Step 5: 커밋** ```bash git add crates/voltex_audio/Cargo.toml crates/voltex_audio/src/spatial.rs crates/voltex_audio/src/lib.rs git commit -m "feat(audio): add 3D audio spatial functions (distance attenuation, stereo panning)" ``` --- ## Task 2: mixing.rs에 spatial 통합 **Files:** - Modify: `crates/voltex_audio/src/mixing.rs` - [ ] **Step 1: PlayingSound에 spatial 필드 추가 + mix_sounds에 listener 파라미터** Changes to mixing.rs: 1. Add import at top: `use crate::spatial::{Listener, SpatialParams, compute_spatial_gains};` 2. Add field to PlayingSound: ```rust pub struct PlayingSound { pub clip_index: usize, pub position: usize, pub volume: f32, pub looping: bool, pub spatial: Option, // NEW } ``` 3. Update PlayingSound::new to set spatial: None: ```rust impl PlayingSound { pub fn new(clip_index: usize, volume: f32, looping: bool) -> Self { Self { clip_index, position: 0, volume, looping, spatial: None } } pub fn new_3d(clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) -> Self { Self { clip_index, position: 0, volume, looping, spatial: Some(spatial) } } } ``` 4. Add `listener: &Listener` parameter to mix_sounds: ```rust pub fn mix_sounds( output: &mut Vec, playing: &mut Vec, clips: &[AudioClip], device_sample_rate: u32, device_channels: u16, frames: usize, listener: &Listener, // NEW ) ``` 5. Inside the per-sound loop, before writing to output, compute spatial gains: ```rust // After existing setup, before the frame loop: let (vol_left, vol_right) = if let Some(ref sp) = sound.spatial { let (atten, lg, rg) = compute_spatial_gains(listener, sp); (sound.volume * atten * lg, sound.volume * atten * rg) } else { (sound.volume, sound.volume) }; ``` Then use `vol_left` and `vol_right` instead of `sound.volume` when writing to stereo output: - For `device_channels == 2`: left channel uses `vol_left`, right channel uses `vol_right` - For `device_channels == 1`: use `(vol_left + vol_right) * 0.5` 6. Update ALL existing tests to pass `&Listener::default()` as the last argument to mix_sounds. 7. Add new spatial tests: ```rust #[test] fn spatial_2d_unchanged() { // spatial=None should behave exactly like before let clips = vec![make_mono_clip(1.0, 100, 44100)]; let mut playing = vec![PlayingSound::new(0, 0.5, false)]; let mut output = Vec::new(); mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default()); for &s in &output { assert!((s - 0.5).abs() < 1e-5); } } #[test] fn spatial_far_away_silent() { use crate::spatial::SpatialParams; let clips = vec![make_mono_clip(1.0, 100, 44100)]; let spatial = SpatialParams::new( voltex_math::Vec3::new(100.0, 0.0, 0.0), 1.0, 50.0 ); let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; let mut output = Vec::new(); mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default()); // At distance 100, max_distance=50 → attenuation = 0 for &s in &output { assert!(s.abs() < 1e-5, "expected silence, got {}", s); } } #[test] fn spatial_right_panning() { use crate::spatial::SpatialParams; let clips = vec![make_mono_clip(1.0, 100, 44100)]; let spatial = SpatialParams::new( voltex_math::Vec3::new(2.0, 0.0, 0.0), 1.0, 50.0 ); let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; let mut output = Vec::new(); mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default()); // Emitter on the right → right channel louder let left = output[0]; let right = output[1]; assert!(right > left, "right={} should be > left={}", right, left); assert!(right > 0.0); } ``` - [ ] **Step 2: 테스트 실행** Run: `cargo test -p voltex_audio` Expected: 기존 15 (updated) + 8 spatial + 3 mixing_spatial = 26 PASS - [ ] **Step 3: 커밋** ```bash git add crates/voltex_audio/src/mixing.rs git commit -m "feat(audio): integrate spatial 3D audio into mixing pipeline" ``` --- ## Task 3: AudioSystem에 play_3d, set_listener 추가 **Files:** - Modify: `crates/voltex_audio/src/audio_system.rs` - [ ] **Step 1: AudioCommand에 Play3d, SetListener 추가** Add imports: ```rust use crate::spatial::{Listener, SpatialParams}; ``` Add to AudioCommand enum: ```rust Play3d { clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams, }, SetListener { position: voltex_math::Vec3, forward: voltex_math::Vec3, right: voltex_math::Vec3, }, ``` - [ ] **Step 2: AudioSystem에 새 메서드 추가** ```rust pub fn play_3d(&self, clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) { let _ = self.sender.send(AudioCommand::Play3d { clip_index, volume, looping, spatial, }); } pub fn set_listener(&self, position: voltex_math::Vec3, forward: voltex_math::Vec3, right: voltex_math::Vec3) { let _ = self.sender.send(AudioCommand::SetListener { position, forward, right }); } ``` - [ ] **Step 3: audio_thread_windows에서 새 명령 처리** Add `let mut listener = Listener::default();` before the loop. In the command match: ```rust AudioCommand::Play3d { clip_index, volume, looping, spatial } => { playing.push(PlayingSound::new_3d(clip_index, volume, looping, spatial)); } AudioCommand::SetListener { position, forward, right } => { listener = Listener { position, forward, right }; } ``` Update the mix_sounds call to pass `&listener`: ```rust mix_sounds(&mut output, &mut playing, &clips, device_sample_rate, device_channels, buffer_frames, &listener); ``` Also update the existing test in audio_system to pass `&Listener::default()` if needed (the tests use AudioSystem API, not mix_sounds directly, so they should be fine). - [ ] **Step 4: 테스트 실행** Run: `cargo test -p voltex_audio` Expected: all pass Run: `cargo test --workspace` Expected: all pass - [ ] **Step 5: 커밋** ```bash git add crates/voltex_audio/src/audio_system.rs git commit -m "feat(audio): add play_3d and set_listener to AudioSystem" ``` --- ## Task 4: 문서 업데이트 **Files:** - Modify: `docs/STATUS.md` - Modify: `docs/DEFERRED.md` - [ ] **Step 1: STATUS.md에 Phase 6-2 추가** Phase 6-1 아래에: ```markdown ### Phase 6-2: 3D Audio - voltex_audio: Listener, SpatialParams - voltex_audio: distance_attenuation (inverse distance), stereo_pan (equal-power) - voltex_audio: mix_sounds spatial integration (per-sound attenuation + panning) - voltex_audio: play_3d, set_listener API ``` 테스트 수 업데이트. - [ ] **Step 2: DEFERRED.md에 Phase 6-2 미뤄진 항목 추가** ```markdown ## Phase 6-2 - **도플러 효과** — 미구현. 상대 속도 기반 주파수 변조. - **HRTF** — 미구현. 헤드폰용 3D 정위. - **Reverb/Echo** — 미구현. 환경 반사음. - **Occlusion** — 미구현. 벽 뒤 소리 차단. ``` - [ ] **Step 3: 커밋** ```bash git add docs/STATUS.md docs/DEFERRED.md git commit -m "docs: add Phase 6-2 3D audio status and deferred items" ```