# 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, pub sample_rate: u32, pub channels: u16, } impl AudioClip { pub fn new(samples: Vec, 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 { 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 { 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, 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 { 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 { 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, _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) -> Result { 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, clips: Arc>) { let device = match WasapiDevice::new() { Ok(d) => d, Err(e) => { eprintln!("[voltex_audio] WASAPI init failed: {}", e); return; } }; let mut playing: Vec = 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" ```