# Phase 6-3: Mixer 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:** 그룹 기반 믹서 — BGM/SFX/Voice 그룹별 볼륨 제어와 페이드 인/아웃 **Architecture:** `voltex_audio`에 mix_group.rs 추가 (MixGroup enum, GroupState, MixerState). mixing.rs에 group+mixer 통합. audio_system.rs에 SetGroupVolume/FadeGroup 명령 추가. **Tech Stack:** Rust, voltex_audio (기존 mixing, audio_system) **Spec:** `docs/superpowers/specs/2026-03-25-phase6-3-mixer.md` --- ## File Structure - `crates/voltex_audio/src/mix_group.rs` — MixGroup, GroupState, MixerState (Create) - `crates/voltex_audio/src/mixing.rs` — PlayingSound에 group, mix_sounds에 mixer (Modify) - `crates/voltex_audio/src/audio_system.rs` — 새 명령 + 메서드 + 스레드 통합 (Modify) - `crates/voltex_audio/src/lib.rs` — mix_group 등록 (Modify) --- ## Task 1: MixGroup + GroupState + MixerState **Files:** - Create: `crates/voltex_audio/src/mix_group.rs` - Modify: `crates/voltex_audio/src/lib.rs` - [ ] **Step 1: mix_group.rs 작성** ```rust // crates/voltex_audio/src/mix_group.rs #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MixGroup { Master = 0, Bgm = 1, Sfx = 2, Voice = 3, } const GROUP_COUNT: usize = 4; #[derive(Debug, Clone)] pub struct GroupState { pub volume: f32, pub fade_target: f32, pub fade_speed: f32, // units per second, 0 = no fade } impl GroupState { pub fn new() -> Self { Self { volume: 1.0, fade_target: 1.0, fade_speed: 0.0 } } /// Advance fade by dt seconds. pub fn tick(&mut self, dt: f32) { if self.fade_speed <= 0.0 { return; } let diff = self.fade_target - self.volume; if diff.abs() < 1e-6 { self.volume = self.fade_target; self.fade_speed = 0.0; return; } let step = self.fade_speed * dt; if step >= diff.abs() { self.volume = self.fade_target; self.fade_speed = 0.0; } else if diff > 0.0 { self.volume += step; } else { self.volume -= step; } } } pub struct MixerState { groups: [GroupState; GROUP_COUNT], } impl MixerState { pub fn new() -> Self { Self { groups: [ GroupState::new(), // Master GroupState::new(), // Bgm GroupState::new(), // Sfx GroupState::new(), // Voice ], } } pub fn set_volume(&mut self, group: MixGroup, volume: f32) { let g = &mut self.groups[group as usize]; g.volume = volume.clamp(0.0, 1.0); g.fade_target = g.volume; g.fade_speed = 0.0; } pub fn fade(&mut self, group: MixGroup, target: f32, duration: f32) { let g = &mut self.groups[group as usize]; let target = target.clamp(0.0, 1.0); if duration <= 0.0 { g.volume = target; g.fade_target = target; g.fade_speed = 0.0; return; } g.fade_target = target; g.fade_speed = (target - g.volume).abs() / duration; } pub fn tick(&mut self, dt: f32) { for g in self.groups.iter_mut() { g.tick(dt); } } pub fn volume(&self, group: MixGroup) -> f32 { self.groups[group as usize].volume } /// Effective volume = group volume * master volume. /// For Master group, returns just its own volume. pub fn effective_volume(&self, group: MixGroup) -> f32 { if group == MixGroup::Master { self.groups[MixGroup::Master as usize].volume } else { self.groups[group as usize].volume * self.groups[MixGroup::Master as usize].volume } } } #[cfg(test)] mod tests { use super::*; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-3 } #[test] fn test_group_state_tick_fade() { let mut g = GroupState::new(); g.fade_target = 0.0; g.fade_speed = 2.0; // 2.0 per second g.tick(0.25); // 0.5 step assert!(approx(g.volume, 0.5)); g.tick(0.25); assert!(approx(g.volume, 0.0)); assert!(approx(g.fade_speed, 0.0)); // fade done } #[test] fn test_group_state_tick_no_overshoot() { let mut g = GroupState::new(); // volume=1.0 g.fade_target = 0.5; g.fade_speed = 10.0; // very fast g.tick(1.0); // would overshoot assert!(approx(g.volume, 0.5)); } #[test] fn test_mixer_set_volume() { let mut m = MixerState::new(); m.set_volume(MixGroup::Bgm, 0.5); assert!(approx(m.volume(MixGroup::Bgm), 0.5)); // Should cancel any fade assert!(approx(m.groups[MixGroup::Bgm as usize].fade_speed, 0.0)); } #[test] fn test_mixer_fade() { let mut m = MixerState::new(); m.fade(MixGroup::Sfx, 0.0, 1.0); // fade to 0 over 1 second m.tick(0.5); assert!(approx(m.volume(MixGroup::Sfx), 0.5)); m.tick(0.5); assert!(approx(m.volume(MixGroup::Sfx), 0.0)); } #[test] fn test_effective_volume() { let mut m = MixerState::new(); m.set_volume(MixGroup::Master, 0.5); m.set_volume(MixGroup::Sfx, 0.8); assert!(approx(m.effective_volume(MixGroup::Sfx), 0.4)); // 0.5 * 0.8 assert!(approx(m.effective_volume(MixGroup::Master), 0.5)); // master is just itself } #[test] fn test_master_zero_mutes_all() { let mut m = MixerState::new(); m.set_volume(MixGroup::Master, 0.0); assert!(approx(m.effective_volume(MixGroup::Bgm), 0.0)); assert!(approx(m.effective_volume(MixGroup::Sfx), 0.0)); assert!(approx(m.effective_volume(MixGroup::Voice), 0.0)); } #[test] fn test_fade_up() { let mut m = MixerState::new(); m.set_volume(MixGroup::Bgm, 0.0); m.fade(MixGroup::Bgm, 1.0, 2.0); // fade up over 2 seconds m.tick(1.0); assert!(approx(m.volume(MixGroup::Bgm), 0.5)); m.tick(1.0); assert!(approx(m.volume(MixGroup::Bgm), 1.0)); } } ``` - [ ] **Step 2: lib.rs에 mix_group 모듈 등록** ```rust pub mod mix_group; pub use mix_group::{MixGroup, MixerState}; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_audio` Expected: 기존 26 + 7 = 33 PASS - [ ] **Step 4: 커밋** ```bash git add crates/voltex_audio/src/mix_group.rs crates/voltex_audio/src/lib.rs git commit -m "feat(audio): add MixGroup, GroupState, and MixerState with fade support" ``` --- ## Task 2: mixing.rs + audio_system.rs 통합 **Files:** - Modify: `crates/voltex_audio/src/mixing.rs` - Modify: `crates/voltex_audio/src/audio_system.rs` NOTE: 두 파일을 함께 수정해야 컴파일됨 (mix_sounds 시그니처 변경). - [ ] **Step 1: mixing.rs 수정** Read the current file first. Changes: 1. Add import: `use crate::mix_group::{MixGroup, MixerState};` 2. Add `group: MixGroup` field to PlayingSound: ```rust pub struct PlayingSound { pub clip_index: usize, pub position: usize, pub volume: f32, pub looping: bool, pub spatial: Option, pub group: MixGroup, // NEW } ``` 3. Update PlayingSound::new: add `group: MixGroup::Sfx` 4. Update PlayingSound::new_3d: add `group: MixGroup::Sfx` 5. Add `mixer: &MixerState` parameter to mix_sounds (last parameter): ```rust pub fn mix_sounds( output: &mut Vec, playing: &mut Vec, clips: &[AudioClip], device_sample_rate: u32, device_channels: u16, frames: usize, listener: &Listener, mixer: &MixerState, // NEW ) ``` 6. In the per-sound volume computation, multiply by mixer effective volume: ```rust let group_vol = mixer.effective_volume(sound.group); let base_vol = sound.volume * group_vol; ``` Then use `base_vol` where `sound.volume` was used for spatial/non-spatial gain calculation. 7. Update ALL existing tests to pass `&MixerState::new()` as last arg. 8. Add 2 new tests: ```rust #[test] fn group_volume_applied() { use crate::mix_group::{MixGroup, MixerState}; let clips = vec![make_mono_clip(1.0, 100, 44100)]; let mut playing = vec![PlayingSound::new(0, 1.0, false)]; // group=Sfx let mut output = Vec::new(); let mut mixer = MixerState::new(); mixer.set_volume(MixGroup::Sfx, 0.5); mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer); for &s in &output { assert!((s - 0.5).abs() < 1e-5, "expected 0.5, got {}", s); } } #[test] fn master_zero_mutes_output() { use crate::mix_group::{MixGroup, MixerState}; let clips = vec![make_mono_clip(1.0, 100, 44100)]; let mut playing = vec![PlayingSound::new(0, 1.0, false)]; let mut output = Vec::new(); let mut mixer = MixerState::new(); mixer.set_volume(MixGroup::Master, 0.0); mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer); for &s in &output { assert!(s.abs() < 1e-5, "expected silence, got {}", s); } } ``` - [ ] **Step 2: audio_system.rs 수정** Read the current file first. Changes: 1. Add import: `use crate::mix_group::{MixGroup, MixerState};` 2. Add to AudioCommand enum: ```rust SetGroupVolume { group: MixGroup, volume: f32 }, FadeGroup { group: MixGroup, target: f32, duration: f32 }, ``` 3. Add methods to AudioSystem: ```rust pub fn set_group_volume(&self, group: MixGroup, volume: f32) { let _ = self.sender.send(AudioCommand::SetGroupVolume { group, volume }); } pub fn fade_group(&self, group: MixGroup, target: f32, duration: f32) { let _ = self.sender.send(AudioCommand::FadeGroup { group, target, duration }); } ``` 4. In audio_thread_windows: add `let mut mixer = MixerState::new();` before loop 5. Handle new commands: ```rust AudioCommand::SetGroupVolume { group, volume } => { mixer.set_volume(group, volume); } AudioCommand::FadeGroup { group, target, duration } => { mixer.fade(group, target, duration); } ``` 6. Add `mixer.tick(0.005);` at the start of each loop iteration (5ms = 0.005s) 7. Update mix_sounds call to pass `&mixer` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_audio` Expected: 35 PASS (33 + 2 new mixing tests) Run: `cargo test --workspace` Expected: all pass - [ ] **Step 4: 커밋** ```bash git add crates/voltex_audio/src/mixing.rs crates/voltex_audio/src/audio_system.rs git commit -m "feat(audio): integrate mixer groups into mixing pipeline and AudioSystem" ``` --- ## Task 3: 문서 업데이트 **Files:** - Modify: `docs/STATUS.md` - Modify: `docs/DEFERRED.md` - [ ] **Step 1: STATUS.md에 Phase 6-3 추가** Phase 6-2 아래에: ```markdown ### Phase 6-3: Mixer - voltex_audio: MixGroup (Master, Bgm, Sfx, Voice), MixerState - voltex_audio: GroupState with linear fade (tick-based) - voltex_audio: effective_volume (group * master) - voltex_audio: set_group_volume, fade_group API ``` 테스트 수 업데이트. 다음을 Phase 7로 변경. - [ ] **Step 2: DEFERRED.md에 Phase 6-3 미뤄진 항목 추가** ```markdown ## Phase 6-3 - **동적 그룹 생성** — 고정 4개(Master/Bgm/Sfx/Voice)만. 런타임 추가 불가. - **그룹 간 라우팅/버스** — 미구현. 단순 Master → 개별 그룹 구조만. - **이펙트 체인** — Reverb, EQ 등 미구현. - **비선형 페이드 커브** — 선형 페이드만. ``` - [ ] **Step 3: 커밋** ```bash git add docs/STATUS.md docs/DEFERRED.md git commit -m "docs: add Phase 6-3 mixer status and deferred items" ```