413 lines
12 KiB
Markdown
413 lines
12 KiB
Markdown
# 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<SpatialParams>,
|
|
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<f32>,
|
|
playing: &mut Vec<PlayingSound>,
|
|
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"
|
|
```
|