docs: add Phase 5-1 through 6-3 specs, plans, and Cargo.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
462
docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md
Normal file
462
docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# 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<SpatialParams>, // 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<f32>,
|
||||
playing: &mut Vec<PlayingSound>,
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user