1192 lines
35 KiB
Markdown
1192 lines
35 KiB
Markdown
# Phase 6-1: Audio System Foundation 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:** WAV 파일을 로드하고 WASAPI를 통해 소리를 재생하는 기본 오디오 시스템
|
|
|
|
**Architecture:** `voltex_audio` crate 신규 생성. WAV 파서와 믹싱 로직은 순수 함수로 테스트 가능하게 구현. WASAPI FFI는 별도 모듈. mpsc channel로 메인↔오디오 스레드 통신. AudioSystem이 public API 제공.
|
|
|
|
**Tech Stack:** Rust, Windows WASAPI (COM FFI), std::sync::mpsc, std::thread
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-25-phase6-1-audio.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### voltex_audio (신규)
|
|
- `crates/voltex_audio/Cargo.toml` — crate 설정 (Create)
|
|
- `crates/voltex_audio/src/lib.rs` — public exports (Create)
|
|
- `crates/voltex_audio/src/audio_clip.rs` — AudioClip 타입 (Create)
|
|
- `crates/voltex_audio/src/wav.rs` — WAV 파서 (Create)
|
|
- `crates/voltex_audio/src/mixing.rs` — 믹싱 순수 함수 (Create)
|
|
- `crates/voltex_audio/src/wasapi.rs` — WASAPI FFI 바인딩 (Create)
|
|
- `crates/voltex_audio/src/audio_system.rs` — AudioSystem API + 오디오 스레드 (Create)
|
|
|
|
### Workspace (수정)
|
|
- `Cargo.toml` — workspace members + dependencies (Modify)
|
|
|
|
### Example (신규)
|
|
- `examples/audio_demo/Cargo.toml` (Create)
|
|
- `examples/audio_demo/src/main.rs` (Create)
|
|
|
|
---
|
|
|
|
## Task 1: Crate 설정 + AudioClip 타입
|
|
|
|
**Files:**
|
|
- Create: `crates/voltex_audio/Cargo.toml`
|
|
- Create: `crates/voltex_audio/src/lib.rs`
|
|
- Create: `crates/voltex_audio/src/audio_clip.rs`
|
|
- Modify: `Cargo.toml` (workspace)
|
|
|
|
- [ ] **Step 1: Cargo.toml 생성**
|
|
|
|
```toml
|
|
# crates/voltex_audio/Cargo.toml
|
|
[package]
|
|
name = "voltex_audio"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
```
|
|
|
|
- [ ] **Step 2: workspace에 추가**
|
|
|
|
`Cargo.toml` workspace members에 `"crates/voltex_audio"` 추가.
|
|
workspace.dependencies에 `voltex_audio = { path = "crates/voltex_audio" }` 추가.
|
|
|
|
- [ ] **Step 3: audio_clip.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/audio_clip.rs
|
|
|
|
/// Decoded audio data with interleaved f32 samples normalized to -1.0..1.0.
|
|
#[derive(Clone)]
|
|
pub struct AudioClip {
|
|
pub samples: Vec<f32>,
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
}
|
|
|
|
impl AudioClip {
|
|
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
|
|
Self { samples, sample_rate, channels }
|
|
}
|
|
|
|
/// Number of sample frames (total samples / channels).
|
|
pub fn frame_count(&self) -> usize {
|
|
if self.channels == 0 { 0 } else { self.samples.len() / self.channels as usize }
|
|
}
|
|
|
|
/// Duration in seconds.
|
|
pub fn duration(&self) -> f32 {
|
|
if self.sample_rate == 0 { 0.0 } else { self.frame_count() as f32 / self.sample_rate as f32 }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_mono_clip() {
|
|
let clip = AudioClip::new(vec![0.0; 44100], 44100, 1);
|
|
assert_eq!(clip.frame_count(), 44100);
|
|
assert!((clip.duration() - 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stereo_clip() {
|
|
let clip = AudioClip::new(vec![0.0; 88200], 44100, 2);
|
|
assert_eq!(clip.frame_count(), 44100);
|
|
assert!((clip.duration() - 1.0).abs() < 1e-5);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: lib.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/lib.rs
|
|
pub mod audio_clip;
|
|
pub use audio_clip::AudioClip;
|
|
```
|
|
|
|
- [ ] **Step 5: 테스트 실행**
|
|
|
|
Run: `cargo test -p voltex_audio`
|
|
Expected: 2 PASS
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
git add crates/voltex_audio/ Cargo.toml
|
|
git commit -m "feat(audio): add voltex_audio crate with AudioClip type"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: WAV 파서
|
|
|
|
**Files:**
|
|
- Create: `crates/voltex_audio/src/wav.rs`
|
|
- Modify: `crates/voltex_audio/src/lib.rs`
|
|
|
|
- [ ] **Step 1: wav.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/wav.rs
|
|
use crate::audio_clip::AudioClip;
|
|
|
|
/// Parse a WAV file from raw bytes. Supports PCM 16-bit mono/stereo only.
|
|
pub fn parse_wav(data: &[u8]) -> Result<AudioClip, String> {
|
|
if data.len() < 44 {
|
|
return Err("WAV too short".into());
|
|
}
|
|
|
|
// RIFF header
|
|
if &data[0..4] != b"RIFF" {
|
|
return Err("Missing RIFF header".into());
|
|
}
|
|
if &data[8..12] != b"WAVE" {
|
|
return Err("Missing WAVE identifier".into());
|
|
}
|
|
|
|
// Find fmt chunk
|
|
let (fmt_offset, _fmt_size) = find_chunk(data, b"fmt ")
|
|
.ok_or("Missing fmt chunk")?;
|
|
|
|
let format_tag = read_u16_le(data, fmt_offset);
|
|
if format_tag != 1 {
|
|
return Err(format!("Unsupported format tag: {} (only PCM=1)", format_tag));
|
|
}
|
|
|
|
let channels = read_u16_le(data, fmt_offset + 2);
|
|
if channels != 1 && channels != 2 {
|
|
return Err(format!("Unsupported channel count: {}", channels));
|
|
}
|
|
|
|
let sample_rate = read_u32_le(data, fmt_offset + 4);
|
|
let bits_per_sample = read_u16_le(data, fmt_offset + 14);
|
|
if bits_per_sample != 16 {
|
|
return Err(format!("Unsupported bits per sample: {} (only 16)", bits_per_sample));
|
|
}
|
|
|
|
// Find data chunk
|
|
let (data_offset, data_size) = find_chunk(data, b"data")
|
|
.ok_or("Missing data chunk")?;
|
|
|
|
let num_samples = data_size / 2; // 16-bit = 2 bytes per sample
|
|
let mut samples = Vec::with_capacity(num_samples);
|
|
|
|
for i in 0..num_samples {
|
|
let offset = data_offset + i * 2;
|
|
if offset + 2 > data.len() {
|
|
break;
|
|
}
|
|
let raw = read_i16_le(data, offset);
|
|
samples.push(raw as f32 / 32768.0);
|
|
}
|
|
|
|
Ok(AudioClip::new(samples, sample_rate, channels))
|
|
}
|
|
|
|
fn find_chunk(data: &[u8], id: &[u8; 4]) -> Option<(usize, usize)> {
|
|
let mut offset = 12; // skip RIFF header
|
|
while offset + 8 <= data.len() {
|
|
let chunk_id = &data[offset..offset + 4];
|
|
let chunk_size = read_u32_le(data, offset + 4) as usize;
|
|
if chunk_id == id {
|
|
return Some((offset + 8, chunk_size));
|
|
}
|
|
offset += 8 + chunk_size;
|
|
// Chunks are 2-byte aligned
|
|
if chunk_size % 2 != 0 {
|
|
offset += 1;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_u16_le(data: &[u8], offset: usize) -> u16 {
|
|
u16::from_le_bytes([data[offset], data[offset + 1]])
|
|
}
|
|
|
|
fn read_u32_le(data: &[u8], offset: usize) -> u32 {
|
|
u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]])
|
|
}
|
|
|
|
fn read_i16_le(data: &[u8], offset: usize) -> i16 {
|
|
i16::from_le_bytes([data[offset], data[offset + 1]])
|
|
}
|
|
|
|
/// Generate a WAV file in memory (PCM 16-bit mono). Useful for testing.
|
|
pub fn generate_wav_bytes(samples_f32: &[f32], sample_rate: u32) -> Vec<u8> {
|
|
let num_samples = samples_f32.len();
|
|
let data_size = num_samples * 2; // 16-bit
|
|
let file_size = 36 + data_size;
|
|
|
|
let mut buf = Vec::with_capacity(file_size + 8);
|
|
|
|
// RIFF header
|
|
buf.extend_from_slice(b"RIFF");
|
|
buf.extend_from_slice(&(file_size as u32).to_le_bytes());
|
|
buf.extend_from_slice(b"WAVE");
|
|
|
|
// fmt chunk
|
|
buf.extend_from_slice(b"fmt ");
|
|
buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
|
|
buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
|
buf.extend_from_slice(&1u16.to_le_bytes()); // mono
|
|
buf.extend_from_slice(&sample_rate.to_le_bytes());
|
|
buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
|
|
buf.extend_from_slice(&2u16.to_le_bytes()); // block align
|
|
buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
|
|
|
// data chunk
|
|
buf.extend_from_slice(b"data");
|
|
buf.extend_from_slice(&(data_size as u32).to_le_bytes());
|
|
for &s in samples_f32 {
|
|
let clamped = s.clamp(-1.0, 1.0);
|
|
let i16_val = (clamped * 32767.0) as i16;
|
|
buf.extend_from_slice(&i16_val.to_le_bytes());
|
|
}
|
|
|
|
buf
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_valid_wav() {
|
|
let samples = vec![0.0, 0.5, -0.5, 1.0];
|
|
let wav_data = generate_wav_bytes(&samples, 44100);
|
|
let clip = parse_wav(&wav_data).unwrap();
|
|
assert_eq!(clip.sample_rate, 44100);
|
|
assert_eq!(clip.channels, 1);
|
|
assert_eq!(clip.samples.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sample_conversion_accuracy() {
|
|
let samples = vec![1.0, -1.0, 0.0];
|
|
let wav_data = generate_wav_bytes(&samples, 44100);
|
|
let clip = parse_wav(&wav_data).unwrap();
|
|
// 32767/32768 ≈ 0.99997
|
|
assert!((clip.samples[0] - 32767.0 / 32768.0).abs() < 1e-3);
|
|
assert!((clip.samples[1] - (-1.0)).abs() < 1e-3);
|
|
assert!((clip.samples[2]).abs() < 1e-3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_riff() {
|
|
let data = vec![0u8; 44];
|
|
assert!(parse_wav(&data).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_too_short() {
|
|
let data = vec![0u8; 10];
|
|
assert!(parse_wav(&data).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_roundtrip() {
|
|
let original = vec![0.25, -0.25, 0.5, -0.5];
|
|
let wav_data = generate_wav_bytes(&original, 22050);
|
|
let clip = parse_wav(&wav_data).unwrap();
|
|
assert_eq!(clip.sample_rate, 22050);
|
|
assert_eq!(clip.channels, 1);
|
|
assert_eq!(clip.samples.len(), 4);
|
|
for (a, b) in original.iter().zip(clip.samples.iter()) {
|
|
assert!((a - b).abs() < 0.001);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: lib.rs에 wav 모듈 등록**
|
|
|
|
```rust
|
|
pub mod wav;
|
|
pub use wav::{parse_wav, generate_wav_bytes};
|
|
```
|
|
|
|
- [ ] **Step 3: 테스트 실행**
|
|
|
|
Run: `cargo test -p voltex_audio`
|
|
Expected: 7 PASS (2 clip + 5 wav)
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
git add crates/voltex_audio/src/wav.rs crates/voltex_audio/src/lib.rs
|
|
git commit -m "feat(audio): add WAV parser with PCM 16-bit support"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: 믹싱 순수 함수
|
|
|
|
**Files:**
|
|
- Create: `crates/voltex_audio/src/mixing.rs`
|
|
- Modify: `crates/voltex_audio/src/lib.rs`
|
|
|
|
- [ ] **Step 1: mixing.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/mixing.rs
|
|
use crate::audio_clip::AudioClip;
|
|
|
|
/// A currently playing sound instance.
|
|
pub struct PlayingSound {
|
|
pub clip_index: usize,
|
|
pub position: usize, // sample frame position
|
|
pub volume: f32,
|
|
pub looping: bool,
|
|
}
|
|
|
|
/// Mix active sounds into an interleaved f32 output buffer.
|
|
/// `output` has `frames * device_channels` elements.
|
|
/// Returns indices of sounds that finished (non-looping, reached end).
|
|
pub fn mix_sounds(
|
|
output: &mut [f32],
|
|
playing: &mut Vec<PlayingSound>,
|
|
clips: &[AudioClip],
|
|
device_sample_rate: u32,
|
|
device_channels: u16,
|
|
frames: usize,
|
|
) {
|
|
// Zero output
|
|
for s in output.iter_mut() {
|
|
*s = 0.0;
|
|
}
|
|
|
|
let mut finished = Vec::new();
|
|
|
|
for (idx, sound) in playing.iter_mut().enumerate() {
|
|
if sound.clip_index >= clips.len() {
|
|
finished.push(idx);
|
|
continue;
|
|
}
|
|
let clip = &clips[sound.clip_index];
|
|
let clip_frames = clip.frame_count();
|
|
if clip_frames == 0 {
|
|
finished.push(idx);
|
|
continue;
|
|
}
|
|
|
|
let rate_ratio = clip.sample_rate as f64 / device_sample_rate as f64;
|
|
|
|
for frame in 0..frames {
|
|
let clip_frame_f = sound.position as f64 + frame as f64 * rate_ratio;
|
|
let clip_frame = clip_frame_f as usize;
|
|
|
|
if clip_frame >= clip_frames {
|
|
if sound.looping {
|
|
sound.position = 0;
|
|
// Continue from beginning for remaining frames
|
|
// (simplified: just stop for this buffer, will resume next call)
|
|
break;
|
|
} else {
|
|
finished.push(idx);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let out_offset = frame * device_channels as usize;
|
|
|
|
for ch in 0..device_channels as usize {
|
|
let clip_ch = if ch < clip.channels as usize { ch } else { 0 };
|
|
let sample_idx = clip_frame * clip.channels as usize + clip_ch;
|
|
if sample_idx < clip.samples.len() {
|
|
let sample = clip.samples[sample_idx] * sound.volume;
|
|
if out_offset + ch < output.len() {
|
|
output[out_offset + ch] += sample;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Advance position
|
|
let advanced = (frames as f64 * rate_ratio) as usize;
|
|
sound.position += advanced;
|
|
|
|
// Handle looping wrap
|
|
if sound.position >= clip_frames && sound.looping {
|
|
sound.position %= clip_frames;
|
|
}
|
|
}
|
|
|
|
// Remove finished sounds (reverse order to preserve indices)
|
|
finished.sort_unstable();
|
|
finished.dedup();
|
|
for &idx in finished.iter().rev() {
|
|
if idx < playing.len() {
|
|
playing.remove(idx);
|
|
}
|
|
}
|
|
|
|
// Clamp output
|
|
for s in output.iter_mut() {
|
|
*s = s.clamp(-1.0, 1.0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::AudioClip;
|
|
|
|
#[test]
|
|
fn test_single_sound_volume() {
|
|
let clip = AudioClip::new(vec![0.5, 0.5, 0.5, 0.5], 44100, 1);
|
|
let mut playing = vec![PlayingSound {
|
|
clip_index: 0, position: 0, volume: 0.5, looping: false,
|
|
}];
|
|
let mut output = vec![0.0; 4];
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4);
|
|
for s in &output {
|
|
assert!((*s - 0.25).abs() < 1e-5); // 0.5 * 0.5
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_sounds_sum() {
|
|
let clip = AudioClip::new(vec![0.4; 4], 44100, 1);
|
|
let mut playing = vec![
|
|
PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false },
|
|
PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false },
|
|
];
|
|
let mut output = vec![0.0; 4];
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4);
|
|
for s in &output {
|
|
assert!((*s - 0.8).abs() < 1e-5); // 0.4 + 0.4
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipping() {
|
|
let clip = AudioClip::new(vec![0.9; 4], 44100, 1);
|
|
let mut playing = vec![
|
|
PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false },
|
|
PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false },
|
|
];
|
|
let mut output = vec![0.0; 4];
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4);
|
|
for s in &output {
|
|
assert!(*s <= 1.0); // clamped
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_looping_removal() {
|
|
let clip = AudioClip::new(vec![0.5, 0.5], 44100, 1);
|
|
let mut playing = vec![PlayingSound {
|
|
clip_index: 0, position: 0, volume: 1.0, looping: false,
|
|
}];
|
|
let mut output = vec![0.0; 10]; // more frames than clip has
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 10);
|
|
assert!(playing.is_empty()); // sound was removed
|
|
}
|
|
|
|
#[test]
|
|
fn test_looping_continues() {
|
|
let clip = AudioClip::new(vec![0.5, 0.5], 44100, 1);
|
|
let mut playing = vec![PlayingSound {
|
|
clip_index: 0, position: 0, volume: 1.0, looping: true,
|
|
}];
|
|
let mut output = vec![0.0; 10];
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 10);
|
|
assert_eq!(playing.len(), 1); // still playing
|
|
}
|
|
|
|
#[test]
|
|
fn test_mono_to_stereo() {
|
|
let clip = AudioClip::new(vec![0.7; 4], 44100, 1);
|
|
let mut playing = vec![PlayingSound {
|
|
clip_index: 0, position: 0, volume: 1.0, looping: false,
|
|
}];
|
|
let mut output = vec![0.0; 8]; // 4 frames * 2 channels
|
|
mix_sounds(&mut output, &mut playing, &[clip], 44100, 2, 4);
|
|
// Both channels should have the mono sample
|
|
for frame in 0..4 {
|
|
assert!((output[frame * 2] - 0.7).abs() < 1e-5);
|
|
assert!((output[frame * 2 + 1] - 0.7).abs() < 1e-5);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: lib.rs에 mixing 모듈 등록**
|
|
|
|
```rust
|
|
pub mod mixing;
|
|
pub use mixing::{PlayingSound, mix_sounds};
|
|
```
|
|
|
|
- [ ] **Step 3: 테스트 실행**
|
|
|
|
Run: `cargo test -p voltex_audio`
|
|
Expected: 13 PASS (2 clip + 5 wav + 6 mixing)
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
git add crates/voltex_audio/src/mixing.rs crates/voltex_audio/src/lib.rs
|
|
git commit -m "feat(audio): add mixing functions with volume, looping, and channel conversion"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: WASAPI FFI 바인딩
|
|
|
|
**Files:**
|
|
- Create: `crates/voltex_audio/src/wasapi.rs`
|
|
- Modify: `crates/voltex_audio/src/lib.rs`
|
|
|
|
NOTE: 이 태스크는 Windows FFI 코드로, 단위 테스트 불가. 컴파일 확인만.
|
|
|
|
- [ ] **Step 1: wasapi.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/wasapi.rs
|
|
//! WASAPI FFI bindings for Windows audio output.
|
|
//! This module is only compiled on Windows.
|
|
#![allow(non_snake_case, non_camel_case_types, dead_code)]
|
|
|
|
use std::ffi::c_void;
|
|
use std::ptr;
|
|
|
|
// --- COM types ---
|
|
type HRESULT = i32;
|
|
type UINT = u32;
|
|
type DWORD = u32;
|
|
type WORD = u16;
|
|
type BOOL = i32;
|
|
type HANDLE = *mut c_void;
|
|
type REFERENCE_TIME = i64;
|
|
|
|
const S_OK: HRESULT = 0;
|
|
const COINIT_MULTITHREADED: DWORD = 0x0;
|
|
const CLSCTX_ALL: DWORD = 0x17;
|
|
const AUDCLNT_SHAREMODE_SHARED: u32 = 0;
|
|
const AUDCLNT_STREAMFLAGS_EVENTCALLBACK: DWORD = 0x00040000;
|
|
|
|
#[repr(C)]
|
|
struct GUID {
|
|
data1: u32,
|
|
data2: u16,
|
|
data3: u16,
|
|
data4: [u8; 8],
|
|
}
|
|
|
|
// CLSIDs and IIDs
|
|
const CLSID_MMDEVICE_ENUMERATOR: GUID = GUID {
|
|
data1: 0xBCDE0395, data2: 0xE52F, data3: 0x467C,
|
|
data4: [0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E],
|
|
};
|
|
|
|
const IID_IMMDEVICE_ENUMERATOR: GUID = GUID {
|
|
data1: 0xA95664D2, data2: 0x9614, data3: 0x4F35,
|
|
data4: [0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6],
|
|
};
|
|
|
|
const IID_IAUDIO_CLIENT: GUID = GUID {
|
|
data1: 0x1CB9AD4C, data2: 0xDBFA, data3: 0x4c32,
|
|
data4: [0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2],
|
|
};
|
|
|
|
const IID_IAUDIO_RENDER_CLIENT: GUID = GUID {
|
|
data1: 0xF294ACFC, data2: 0x3146, data3: 0x4483,
|
|
data4: [0xA7, 0xBF, 0xAD, 0xDC, 0xA7, 0xC2, 0x60, 0xE2],
|
|
};
|
|
|
|
// eRender = 0, eConsole = 0
|
|
const E_RENDER: u32 = 0;
|
|
const E_CONSOLE: u32 = 0;
|
|
|
|
#[repr(C)]
|
|
pub struct WAVEFORMATEX {
|
|
pub wFormatTag: WORD,
|
|
pub nChannels: WORD,
|
|
pub nSamplesPerSec: DWORD,
|
|
pub nAvgBytesPerSec: DWORD,
|
|
pub nBlockAlign: WORD,
|
|
pub wBitsPerSample: WORD,
|
|
pub cbSize: WORD,
|
|
}
|
|
|
|
const WAVE_FORMAT_IEEE_FLOAT: WORD = 0x0003;
|
|
const WAVE_FORMAT_PCM: WORD = 0x0001;
|
|
|
|
// --- COM vtable definitions (raw pointers) ---
|
|
|
|
extern "system" {
|
|
fn CoInitializeEx(reserved: *mut c_void, coinit: DWORD) -> HRESULT;
|
|
fn CoUninitialize();
|
|
fn CoCreateInstance(
|
|
rclsid: *const GUID, outer: *mut c_void, ctx: DWORD,
|
|
riid: *const GUID, ppv: *mut *mut c_void,
|
|
) -> HRESULT;
|
|
fn CoTaskMemFree(pv: *mut c_void);
|
|
}
|
|
|
|
// IUnknown vtable
|
|
#[repr(C)]
|
|
struct IUnknownVtbl {
|
|
QueryInterface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
|
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
|
|
Release: unsafe extern "system" fn(*mut c_void) -> u32,
|
|
}
|
|
|
|
// IMMDeviceEnumerator vtable
|
|
#[repr(C)]
|
|
struct IMMDeviceEnumeratorVtbl {
|
|
base: IUnknownVtbl,
|
|
EnumAudioEndpoints: *const c_void,
|
|
GetDefaultAudioEndpoint: unsafe extern "system" fn(
|
|
*mut c_void, u32, u32, *mut *mut c_void,
|
|
) -> HRESULT,
|
|
GetDevice: *const c_void,
|
|
RegisterEndpointNotificationCallback: *const c_void,
|
|
UnregisterEndpointNotificationCallback: *const c_void,
|
|
}
|
|
|
|
// IMMDevice vtable
|
|
#[repr(C)]
|
|
struct IMMDeviceVtbl {
|
|
base: IUnknownVtbl,
|
|
Activate: unsafe extern "system" fn(
|
|
*mut c_void, *const GUID, DWORD, *mut c_void, *mut *mut c_void,
|
|
) -> HRESULT,
|
|
OpenPropertyStore: *const c_void,
|
|
GetId: *const c_void,
|
|
GetState: *const c_void,
|
|
}
|
|
|
|
// IAudioClient vtable
|
|
#[repr(C)]
|
|
struct IAudioClientVtbl {
|
|
base: IUnknownVtbl,
|
|
Initialize: unsafe extern "system" fn(
|
|
*mut c_void, u32, DWORD, REFERENCE_TIME, REFERENCE_TIME,
|
|
*const WAVEFORMATEX, *const c_void,
|
|
) -> HRESULT,
|
|
GetBufferSize: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT,
|
|
GetStreamLatency: *const c_void,
|
|
GetCurrentPadding: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT,
|
|
IsFormatSupported: *const c_void,
|
|
GetMixFormat: unsafe extern "system" fn(*mut c_void, *mut *mut WAVEFORMATEX) -> HRESULT,
|
|
GetDevicePeriod: *const c_void,
|
|
Start: unsafe extern "system" fn(*mut c_void) -> HRESULT,
|
|
Stop: unsafe extern "system" fn(*mut c_void) -> HRESULT,
|
|
Reset: unsafe extern "system" fn(*mut c_void) -> HRESULT,
|
|
SetEventHandle: unsafe extern "system" fn(*mut c_void, HANDLE) -> HRESULT,
|
|
GetService: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,
|
|
}
|
|
|
|
// IAudioRenderClient vtable
|
|
#[repr(C)]
|
|
struct IAudioRenderClientVtbl {
|
|
base: IUnknownVtbl,
|
|
GetBuffer: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u8) -> HRESULT,
|
|
ReleaseBuffer: unsafe extern "system" fn(*mut c_void, u32, DWORD) -> HRESULT,
|
|
}
|
|
|
|
/// Wraps WASAPI COM objects for audio output.
|
|
pub struct WasapiDevice {
|
|
client: *mut c_void,
|
|
render_client: *mut c_void,
|
|
buffer_size: u32,
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
pub bits_per_sample: u16,
|
|
pub is_float: bool,
|
|
}
|
|
|
|
unsafe impl Send for WasapiDevice {}
|
|
|
|
impl WasapiDevice {
|
|
/// Initialize WASAPI in shared mode with the default output device.
|
|
pub fn new() -> Result<Self, String> {
|
|
unsafe {
|
|
let hr = CoInitializeEx(ptr::null_mut(), COINIT_MULTITHREADED);
|
|
if hr != S_OK && hr != 1 { // 1 = S_FALSE (already initialized)
|
|
return Err(format!("CoInitializeEx failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Create device enumerator
|
|
let mut enumerator: *mut c_void = ptr::null_mut();
|
|
let hr = CoCreateInstance(
|
|
&CLSID_MMDEVICE_ENUMERATOR, ptr::null_mut(), CLSCTX_ALL,
|
|
&IID_IMMDEVICE_ENUMERATOR, &mut enumerator,
|
|
);
|
|
if hr != S_OK {
|
|
return Err(format!("CoCreateInstance failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Get default audio endpoint
|
|
let vtbl = *(enumerator as *mut *const IMMDeviceEnumeratorVtbl);
|
|
let mut device: *mut c_void = ptr::null_mut();
|
|
let hr = ((*vtbl).GetDefaultAudioEndpoint)(enumerator, E_RENDER, E_CONSOLE, &mut device);
|
|
((*vtbl).base.Release)(enumerator);
|
|
if hr != S_OK {
|
|
return Err(format!("GetDefaultAudioEndpoint failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Activate IAudioClient
|
|
let vtbl = *(device as *mut *const IMMDeviceVtbl);
|
|
let mut client: *mut c_void = ptr::null_mut();
|
|
let hr = ((*vtbl).Activate)(device, &IID_IAUDIO_CLIENT, CLSCTX_ALL, ptr::null_mut(), &mut client);
|
|
((*vtbl).base.Release)(device);
|
|
if hr != S_OK {
|
|
return Err(format!("Activate failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Get mix format
|
|
let client_vtbl = *(client as *mut *const IAudioClientVtbl);
|
|
let mut format_ptr: *mut WAVEFORMATEX = ptr::null_mut();
|
|
let hr = ((*client_vtbl).GetMixFormat)(client, &mut format_ptr);
|
|
if hr != S_OK {
|
|
return Err(format!("GetMixFormat failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
let fmt = &*format_ptr;
|
|
let sample_rate = fmt.nSamplesPerSec;
|
|
let channels = fmt.nChannels;
|
|
let bits_per_sample = fmt.wBitsPerSample;
|
|
let is_float = fmt.wFormatTag == WAVE_FORMAT_IEEE_FLOAT
|
|
|| (fmt.wFormatTag == 0xFFFE && bits_per_sample == 32); // EXTENSIBLE float
|
|
|
|
// Initialize client (shared mode, 50ms buffer)
|
|
let buffer_duration: REFERENCE_TIME = 500_000; // 50ms in 100ns units
|
|
let hr = ((*client_vtbl).Initialize)(
|
|
client, AUDCLNT_SHAREMODE_SHARED, 0,
|
|
buffer_duration, 0, format_ptr, ptr::null(),
|
|
);
|
|
CoTaskMemFree(format_ptr as *mut c_void);
|
|
if hr != S_OK {
|
|
return Err(format!("Initialize failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Get buffer size
|
|
let mut buffer_size: u32 = 0;
|
|
((*client_vtbl).GetBufferSize)(client, &mut buffer_size);
|
|
|
|
// Get render client
|
|
let mut render_client: *mut c_void = ptr::null_mut();
|
|
let hr = ((*client_vtbl).GetService)(client, &IID_IAUDIO_RENDER_CLIENT, &mut render_client);
|
|
if hr != S_OK {
|
|
return Err(format!("GetService failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
// Start
|
|
let hr = ((*client_vtbl).Start)(client);
|
|
if hr != S_OK {
|
|
return Err(format!("Start failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
Ok(WasapiDevice {
|
|
client,
|
|
render_client,
|
|
buffer_size,
|
|
sample_rate,
|
|
channels,
|
|
bits_per_sample,
|
|
is_float,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Write f32 samples to the WASAPI buffer. Returns number of frames written.
|
|
pub fn write_samples(&self, samples: &[f32]) -> Result<usize, String> {
|
|
unsafe {
|
|
let client_vtbl = *(self.client as *mut *const IAudioClientVtbl);
|
|
let render_vtbl = *(self.render_client as *mut *const IAudioRenderClientVtbl);
|
|
|
|
let mut padding: u32 = 0;
|
|
((*client_vtbl).GetCurrentPadding)(self.client, &mut padding);
|
|
let available = self.buffer_size - padding;
|
|
|
|
if available == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
let frames_to_write = available.min(samples.len() as u32 / self.channels as u32);
|
|
if frames_to_write == 0 {
|
|
return Ok(0);
|
|
}
|
|
|
|
let mut buffer_ptr: *mut u8 = ptr::null_mut();
|
|
let hr = ((*render_vtbl).GetBuffer)(self.render_client, frames_to_write, &mut buffer_ptr);
|
|
if hr != S_OK {
|
|
return Err(format!("GetBuffer failed: 0x{:08X}", hr));
|
|
}
|
|
|
|
let total_samples = frames_to_write as usize * self.channels as usize;
|
|
|
|
if self.is_float && self.bits_per_sample == 32 {
|
|
// Write f32 directly
|
|
let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut f32, total_samples);
|
|
for i in 0..total_samples {
|
|
out[i] = if i < samples.len() { samples[i] } else { 0.0 };
|
|
}
|
|
} else if self.bits_per_sample == 16 {
|
|
// Convert f32 → i16
|
|
let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut i16, total_samples);
|
|
for i in 0..total_samples {
|
|
let s = if i < samples.len() { samples[i] } else { 0.0 };
|
|
out[i] = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
|
|
}
|
|
}
|
|
|
|
((*render_vtbl).ReleaseBuffer)(self.render_client, frames_to_write, 0);
|
|
Ok(frames_to_write as usize)
|
|
}
|
|
}
|
|
|
|
pub fn buffer_frames(&self) -> u32 {
|
|
self.buffer_size
|
|
}
|
|
}
|
|
|
|
impl Drop for WasapiDevice {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
if !self.client.is_null() {
|
|
let vtbl = *(self.client as *mut *const IAudioClientVtbl);
|
|
((*vtbl).Stop)(self.client);
|
|
((*vtbl).base.Release)(self.client);
|
|
}
|
|
if !self.render_client.is_null() {
|
|
let vtbl = *(self.render_client as *mut *const IAudioRenderClientVtbl);
|
|
((*vtbl).base.Release)(self.render_client);
|
|
}
|
|
CoUninitialize();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: lib.rs에 wasapi 모듈 등록 (Windows 조건부)**
|
|
|
|
```rust
|
|
#[cfg(target_os = "windows")]
|
|
pub mod wasapi;
|
|
```
|
|
|
|
- [ ] **Step 3: 빌드 확인**
|
|
|
|
Run: `cargo build -p voltex_audio`
|
|
Expected: 컴파일 성공 (FFI 선언만이므로 링크 에러 없음)
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
git add crates/voltex_audio/src/wasapi.rs crates/voltex_audio/src/lib.rs
|
|
git commit -m "feat(audio): add WASAPI FFI bindings for Windows audio output"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: AudioSystem + 오디오 스레드
|
|
|
|
**Files:**
|
|
- Create: `crates/voltex_audio/src/audio_system.rs`
|
|
- Modify: `crates/voltex_audio/src/lib.rs`
|
|
|
|
- [ ] **Step 1: audio_system.rs 작성**
|
|
|
|
```rust
|
|
// crates/voltex_audio/src/audio_system.rs
|
|
use std::sync::mpsc::{self, Sender, Receiver};
|
|
use std::sync::Arc;
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::Duration;
|
|
|
|
use crate::audio_clip::AudioClip;
|
|
use crate::mixing::{PlayingSound, mix_sounds};
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use crate::wasapi::WasapiDevice;
|
|
|
|
pub enum AudioCommand {
|
|
Play { clip_index: usize, volume: f32, looping: bool },
|
|
Stop { clip_index: usize },
|
|
SetVolume { clip_index: usize, volume: f32 },
|
|
StopAll,
|
|
Shutdown,
|
|
}
|
|
|
|
pub struct AudioSystem {
|
|
sender: Sender<AudioCommand>,
|
|
_thread: JoinHandle<()>,
|
|
}
|
|
|
|
impl AudioSystem {
|
|
/// Create a new audio system. Clips are shared with the audio thread.
|
|
#[cfg(target_os = "windows")]
|
|
pub fn new(clips: Vec<AudioClip>) -> Result<Self, String> {
|
|
let (sender, receiver) = mpsc::channel();
|
|
let clips = Arc::new(clips);
|
|
|
|
let thread_clips = Arc::clone(&clips);
|
|
let handle = thread::spawn(move || {
|
|
audio_thread(receiver, thread_clips);
|
|
});
|
|
|
|
Ok(AudioSystem {
|
|
sender,
|
|
_thread: handle,
|
|
})
|
|
}
|
|
|
|
pub fn play(&self, clip_index: usize, volume: f32, looping: bool) {
|
|
let _ = self.sender.send(AudioCommand::Play { clip_index, volume, looping });
|
|
}
|
|
|
|
pub fn stop(&self, clip_index: usize) {
|
|
let _ = self.sender.send(AudioCommand::Stop { clip_index });
|
|
}
|
|
|
|
pub fn set_volume(&self, clip_index: usize, volume: f32) {
|
|
let _ = self.sender.send(AudioCommand::SetVolume { clip_index, volume });
|
|
}
|
|
|
|
pub fn stop_all(&self) {
|
|
let _ = self.sender.send(AudioCommand::StopAll);
|
|
}
|
|
}
|
|
|
|
impl Drop for AudioSystem {
|
|
fn drop(&mut self) {
|
|
let _ = self.sender.send(AudioCommand::Shutdown);
|
|
// Thread will exit when it receives Shutdown
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn audio_thread(receiver: Receiver<AudioCommand>, clips: Arc<Vec<AudioClip>>) {
|
|
let device = match WasapiDevice::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
eprintln!("[voltex_audio] WASAPI init failed: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut playing: Vec<PlayingSound> = Vec::new();
|
|
let buffer_frames = device.buffer_frames() as usize;
|
|
let channels = device.channels;
|
|
let sample_rate = device.sample_rate;
|
|
|
|
let mut mix_buffer = vec![0.0f32; buffer_frames * channels as usize];
|
|
|
|
loop {
|
|
// Process commands (non-blocking)
|
|
while let Ok(cmd) = receiver.try_recv() {
|
|
match cmd {
|
|
AudioCommand::Play { clip_index, volume, looping } => {
|
|
playing.push(PlayingSound {
|
|
clip_index, position: 0, volume, looping,
|
|
});
|
|
}
|
|
AudioCommand::Stop { clip_index } => {
|
|
playing.retain(|s| s.clip_index != clip_index);
|
|
}
|
|
AudioCommand::SetVolume { clip_index, volume } => {
|
|
for s in playing.iter_mut() {
|
|
if s.clip_index == clip_index {
|
|
s.volume = volume;
|
|
}
|
|
}
|
|
}
|
|
AudioCommand::StopAll => {
|
|
playing.clear();
|
|
}
|
|
AudioCommand::Shutdown => {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mix and write
|
|
let frames = buffer_frames / 2; // write half buffer at a time
|
|
let sample_count = frames * channels as usize;
|
|
if mix_buffer.len() < sample_count {
|
|
mix_buffer.resize(sample_count, 0.0);
|
|
}
|
|
|
|
mix_sounds(
|
|
&mut mix_buffer[..sample_count],
|
|
&mut playing,
|
|
&clips,
|
|
sample_rate,
|
|
channels,
|
|
frames,
|
|
);
|
|
|
|
match device.write_samples(&mix_buffer[..sample_count]) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
eprintln!("[voltex_audio] Write error: {}", e);
|
|
}
|
|
}
|
|
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: lib.rs에 audio_system 모듈 등록**
|
|
|
|
```rust
|
|
pub mod audio_system;
|
|
pub use audio_system::AudioSystem;
|
|
```
|
|
|
|
- [ ] **Step 3: 빌드 확인**
|
|
|
|
Run: `cargo build -p voltex_audio`
|
|
Expected: 컴파일 성공
|
|
|
|
- [ ] **Step 4: 전체 테스트**
|
|
|
|
Run: `cargo test --workspace`
|
|
Expected: all pass (기존 165 + 13 audio = 178)
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add crates/voltex_audio/src/audio_system.rs crates/voltex_audio/src/lib.rs
|
|
git commit -m "feat(audio): add AudioSystem with WASAPI audio thread"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: audio_demo 예제
|
|
|
|
**Files:**
|
|
- Create: `examples/audio_demo/Cargo.toml`
|
|
- Create: `examples/audio_demo/src/main.rs`
|
|
- Modify: `Cargo.toml` (workspace members)
|
|
|
|
- [ ] **Step 1: Cargo.toml**
|
|
|
|
```toml
|
|
# examples/audio_demo/Cargo.toml
|
|
[package]
|
|
name = "audio_demo"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
voltex_audio.workspace = true
|
|
```
|
|
|
|
- [ ] **Step 2: main.rs — 사인파 생성 + 재생**
|
|
|
|
```rust
|
|
// examples/audio_demo/src/main.rs
|
|
use voltex_audio::{AudioClip, AudioSystem, parse_wav, generate_wav_bytes};
|
|
|
|
fn generate_sine_clip(freq: f32, duration: f32, sample_rate: u32) -> AudioClip {
|
|
let num_samples = (sample_rate as f32 * duration) as usize;
|
|
let mut samples = Vec::with_capacity(num_samples);
|
|
for i in 0..num_samples {
|
|
let t = i as f32 / sample_rate as f32;
|
|
samples.push((t * freq * 2.0 * std::f32::consts::PI).sin() * 0.3);
|
|
}
|
|
AudioClip::new(samples, sample_rate, 1)
|
|
}
|
|
|
|
fn main() {
|
|
println!("=== Voltex Audio Demo ===");
|
|
println!("Generating 440Hz sine wave (2 seconds)...");
|
|
|
|
let clip = generate_sine_clip(440.0, 2.0, 44100);
|
|
let clip2 = generate_sine_clip(660.0, 1.5, 44100);
|
|
|
|
println!("Initializing audio system...");
|
|
let audio = match AudioSystem::new(vec![clip, clip2]) {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
eprintln!("Failed to init audio: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
println!("Playing 440Hz tone...");
|
|
audio.play(0, 0.5, false);
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
|
|
println!("Playing 660Hz tone on top...");
|
|
audio.play(1, 0.3, false);
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
|
|
println!("Done!");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: workspace에 예제 추가**
|
|
|
|
`Cargo.toml` members에 `"examples/audio_demo"` 추가.
|
|
|
|
- [ ] **Step 4: 빌드 확인**
|
|
|
|
Run: `cargo build --bin audio_demo`
|
|
Expected: 빌드 성공
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
git add examples/audio_demo/ Cargo.toml
|
|
git commit -m "feat(audio): add audio_demo example with sine wave playback"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: 문서 업데이트
|
|
|
|
**Files:**
|
|
- Modify: `docs/STATUS.md`
|
|
- Modify: `docs/DEFERRED.md`
|
|
|
|
- [ ] **Step 1: STATUS.md에 Phase 6-1 추가**
|
|
|
|
Phase 5-3 아래에:
|
|
```markdown
|
|
### Phase 6-1: Audio System Foundation
|
|
- voltex_audio: WAV parser (PCM 16-bit, mono/stereo)
|
|
- voltex_audio: AudioClip (f32 samples), mixing (volume, looping, channel conversion)
|
|
- voltex_audio: WASAPI backend (Windows, shared mode, COM FFI)
|
|
- voltex_audio: AudioSystem (channel-based audio thread, play/stop/volume)
|
|
- examples/audio_demo (sine wave playback)
|
|
```
|
|
|
|
crate 구조에 voltex_audio 추가. 테스트 수 업데이트. 예제 수 10으로.
|
|
|
|
- [ ] **Step 2: DEFERRED.md에 Phase 6-1 미뤄진 항목 추가**
|
|
|
|
```markdown
|
|
## Phase 6-1
|
|
|
|
- **macOS/Linux 백엔드** — WASAPI(Windows)만 구현. CoreAudio, ALSA 미구현.
|
|
- **OGG/Vorbis 디코더** — WAV PCM 16-bit만 지원.
|
|
- **24-bit/32-bit WAV** — 16-bit만 파싱.
|
|
- **ECS 통합** — AudioSource 컴포넌트 미구현. AudioSystem 직접 호출.
|
|
- **비동기 로딩** — 동기 로딩만.
|
|
```
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
git add docs/STATUS.md docs/DEFERRED.md
|
|
git commit -m "docs: add Phase 6-1 audio system status and deferred items"
|
|
```
|