Files
game_engine/docs/superpowers/plans/2026-03-25-phase6-3-mixer.md
2026-03-25 11:37:16 +09:00

12 KiB

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 작성

// 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 모듈 등록
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: 커밋
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:

pub struct PlayingSound {
    pub clip_index: usize,
    pub position: usize,
    pub volume: f32,
    pub looping: bool,
    pub spatial: Option<SpatialParams>,
    pub group: MixGroup,  // NEW
}
  1. Update PlayingSound:🆕 add group: MixGroup::Sfx

  2. Update PlayingSound::new_3d: add group: MixGroup::Sfx

  3. Add mixer: &MixerState parameter to mix_sounds (last parameter):

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,
    mixer: &MixerState,  // NEW
)
  1. In the per-sound volume computation, multiply by mixer effective volume:
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.

  1. Update ALL existing tests to pass &MixerState::new() as last arg.

  2. Add 2 new tests:

    #[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:

SetGroupVolume { group: MixGroup, volume: f32 },
FadeGroup { group: MixGroup, target: f32, duration: f32 },
  1. Add methods to AudioSystem:
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 });
}
  1. In audio_thread_windows: add let mut mixer = MixerState::new(); before loop

  2. Handle new commands:

AudioCommand::SetGroupVolume { group, volume } => {
    mixer.set_volume(group, volume);
}
AudioCommand::FadeGroup { group, target, duration } => {
    mixer.fade(group, target, duration);
}
  1. Add mixer.tick(0.005); at the start of each loop iteration (5ms = 0.005s)

  2. 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: 커밋
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 아래에:

### 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 미뤄진 항목 추가
## Phase 6-3

- **동적 그룹 생성** — 고정 4개(Master/Bgm/Sfx/Voice)만. 런타임 추가 불가.
- **그룹 간 라우팅/버스** — 미구현. 단순 Master → 개별 그룹 구조만.
- **이펙트 체인** — Reverb, EQ 등 미구현.
- **비선형 페이드 커브** — 선형 페이드만.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md
git commit -m "docs: add Phase 6-3 mixer status and deferred items"