|
1 | | -#![cfg(target_arch = "wasm32")] |
2 | | -use crate::core::{EngineParams, MusicEngine, VoiceConfig, Waveform, C_MAJOR_PENTATONIC}; |
3 | | -use glam::Vec3; |
4 | | -use instant::Instant; |
5 | | -use std::cell::RefCell; |
6 | | -use std::rc::Rc; |
7 | | -use std::sync::atomic::{AtomicBool, Ordering}; |
8 | | -use wasm_bindgen::prelude::*; |
9 | | -use wasm_bindgen::JsCast; |
10 | | -use wasm_bindgen_futures::spawn_local; |
11 | | -use web_sys as web; |
| 1 | +pub mod core; |
| 2 | +pub mod events; |
| 3 | +pub mod input; |
12 | 4 |
|
| 5 | +#[cfg(target_arch = "wasm32")] |
13 | 6 | mod audio; |
| 7 | +#[cfg(target_arch = "wasm32")] |
14 | 8 | mod camera; |
| 9 | +#[cfg(target_arch = "wasm32")] |
15 | 10 | mod constants; |
16 | | -mod core; |
| 11 | +#[cfg(target_arch = "wasm32")] |
17 | 12 | mod dom; |
18 | | -mod events; |
| 13 | +#[cfg(target_arch = "wasm32")] |
19 | 14 | mod frame; |
20 | | -mod input; |
| 15 | +#[cfg(target_arch = "wasm32")] |
21 | 16 | mod overlay; |
| 17 | +#[cfg(target_arch = "wasm32")] |
22 | 18 | mod render; |
| 19 | +#[cfg(target_arch = "wasm32")] |
| 20 | +mod wasm_app; |
23 | 21 |
|
24 | | -fn wire_canvas_resize(canvas: &web::HtmlCanvasElement) { |
25 | | - dom::sync_canvas_backing_size(canvas); |
26 | | - let canvas_resize = canvas.clone(); |
27 | | - let resize_closure = Closure::wrap(Box::new(move || { |
28 | | - dom::sync_canvas_backing_size(&canvas_resize); |
29 | | - }) as Box<dyn FnMut()>); |
30 | | - if let Some(window) = web::window() { |
31 | | - _ = window |
32 | | - .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()); |
33 | | - } |
34 | | - resize_closure.forget(); |
35 | | -} |
36 | | - |
37 | | -struct InitParts { |
38 | | - audio_ctx: web::AudioContext, |
39 | | - listener_for_tick: web::AudioListener, |
40 | | - engine: Rc<RefCell<MusicEngine>>, |
41 | | - paused: Rc<RefCell<bool>>, |
42 | | -} |
43 | | - |
44 | | -async fn build_audio_and_engine(_document: web::Document) -> anyhow::Result<InitParts> { |
45 | | - let audio_ctx = web::AudioContext::new().map_err(|e| anyhow::anyhow!("{:?}", e))?; |
46 | | - _ = audio_ctx.resume(); |
47 | | - let listener = audio_ctx.listener(); |
48 | | - listener.set_position(0.0, 0.0, 1.5); |
49 | | - |
50 | | - let voice_configs = vec![ |
51 | | - VoiceConfig { |
52 | | - waveform: Waveform::Sine, |
53 | | - base_position: Vec3::new(-1.0, 0.0, 0.0), |
54 | | - trigger_probability: 0.4, |
55 | | - octave_offset: -1, |
56 | | - base_duration: 0.4, |
57 | | - }, |
58 | | - VoiceConfig { |
59 | | - waveform: Waveform::Saw, |
60 | | - base_position: Vec3::new(1.0, 0.0, 0.0), |
61 | | - trigger_probability: 0.6, |
62 | | - octave_offset: 0, |
63 | | - base_duration: 0.25, |
64 | | - }, |
65 | | - VoiceConfig { |
66 | | - waveform: Waveform::Triangle, |
67 | | - base_position: Vec3::new(0.0, 0.0, -1.0), |
68 | | - trigger_probability: 0.3, |
69 | | - octave_offset: 1, |
70 | | - base_duration: 0.6, |
71 | | - }, |
72 | | - ]; |
73 | | - let engine = Rc::new(RefCell::new(MusicEngine::new( |
74 | | - voice_configs, |
75 | | - EngineParams { |
76 | | - bpm: 110.0, |
77 | | - scale: C_MAJOR_PENTATONIC, |
78 | | - root_midi: 60, |
79 | | - detune_cents: 0.0, |
80 | | - }, |
81 | | - 42, |
82 | | - ))); |
83 | | - { |
84 | | - let e = engine.borrow(); |
85 | | - log::info!( |
86 | | - "[engine] voices={} pos0=({:.2},{:.2},{:.2}) pos1=({:.2},{:.2},{:.2}) pos2=({:.2},{:.2},{:.2})", |
87 | | - e.voices.len(), |
88 | | - e.voices[0].position.x, e.voices[0].position.y, e.voices[0].position.z, |
89 | | - e.voices[1].position.x, e.voices[1].position.y, e.voices[1].position.z, |
90 | | - e.voices[2].position.x, e.voices[2].position.y, e.voices[2].position.z |
91 | | - ); |
92 | | - } |
93 | | - let paused = Rc::new(RefCell::new(true)); |
94 | | - Ok(InitParts { |
95 | | - audio_ctx, |
96 | | - listener_for_tick: listener, |
97 | | - engine, |
98 | | - paused, |
99 | | - }) |
100 | | -} |
101 | | - |
102 | | -fn wire_overlay_buttons(audio_ctx: &web::AudioContext, paused: &Rc<RefCell<bool>>) { |
103 | | - if let Some(doc2) = dom::window_document() { |
104 | | - let paused_ok = paused.clone(); |
105 | | - let audio_ok = audio_ctx.clone(); |
106 | | - dom::add_click_listener(&doc2, "overlay-ok", move || { |
107 | | - *paused_ok.borrow_mut() = false; |
108 | | - _ = audio_ok.resume(); |
109 | | - if let Some(w2) = web::window() { |
110 | | - if let Some(d2) = w2.document() { |
111 | | - overlay::hide(&d2); |
112 | | - } |
113 | | - } |
114 | | - }); |
115 | | - |
116 | | - let paused_close = paused.clone(); |
117 | | - let audio_close = audio_ctx.clone(); |
118 | | - dom::add_click_listener(&doc2, "overlay-close", move || { |
119 | | - *paused_close.borrow_mut() = false; |
120 | | - _ = audio_close.resume(); |
121 | | - if let Some(w2) = web::window() { |
122 | | - if let Some(d2) = w2.document() { |
123 | | - overlay::hide(&d2); |
124 | | - } |
125 | | - } |
126 | | - }); |
127 | | - } |
128 | | -} |
129 | | - |
130 | | -#[wasm_bindgen(start)] |
131 | | -pub fn start() -> Result<(), JsValue> { |
132 | | - console_error_panic_hook::set_once(); |
133 | | - console_log::init_with_level(log::Level::Info).ok(); |
134 | | - log::info!("app-web starting"); |
135 | | - |
136 | | - spawn_local(async move { |
137 | | - if let Err(e) = init().await { |
138 | | - log::error!("init error: {:?}", e); |
139 | | - } |
140 | | - }); |
141 | | - Ok(()) |
142 | | -} |
143 | | - |
144 | | -async fn init() -> anyhow::Result<()> { |
145 | | - let window = web::window().ok_or_else(|| anyhow::anyhow!("no window"))?; |
146 | | - let document = window |
147 | | - .document() |
148 | | - .ok_or_else(|| anyhow::anyhow!("no document"))?; |
149 | | - |
150 | | - let canvas_el = document |
151 | | - .get_element_by_id("app-canvas") |
152 | | - .ok_or_else(|| anyhow::anyhow!("missing #app-canvas"))?; |
153 | | - let canvas: web::HtmlCanvasElement = canvas_el |
154 | | - .dyn_into::<web::HtmlCanvasElement>() |
155 | | - .map_err(|e| anyhow::anyhow!(format!("{:?}", e)))?; |
156 | | - |
157 | | - // Note: start overlay is handled below (toggle with 'h') once audio is initialized. |
158 | | - |
159 | | - // Avoid grabbing a 2D context here to allow WebGPU to acquire the canvas |
160 | | - |
161 | | - // Maintain canvas internal pixel size to match CSS size * devicePixelRatio |
162 | | - wire_canvas_resize(&canvas); |
163 | | - |
164 | | - // Prepare a clone for use inside the click closure |
165 | | - let canvas_for_click = canvas.clone(); |
166 | | - |
167 | | - // Start audio graph and scheduling + WebGPU renderer immediately; show overlay until OK/close |
168 | | - static STARTED: AtomicBool = AtomicBool::new(false); |
169 | | - { |
170 | | - if STARTED.swap(true, Ordering::SeqCst) == false { |
171 | | - let canvas_for_click_inner = canvas_for_click.clone(); |
172 | | - spawn_local(async move { |
173 | | - let InitParts { |
174 | | - audio_ctx, |
175 | | - listener_for_tick, |
176 | | - engine, |
177 | | - paused, |
178 | | - } = match build_audio_and_engine(document.clone()).await { |
179 | | - Ok(p) => p, |
180 | | - Err(_) => return, |
181 | | - }; |
182 | | - |
183 | | - wire_overlay_buttons(&audio_ctx, &paused); |
184 | | - events::wire_overlay_toggle_h(&document); |
185 | | - |
186 | | - // FX buses |
187 | | - let fx = match audio::build_fx_buses(&audio_ctx) { |
188 | | - Ok(f) => f, |
189 | | - Err(_) => return, |
190 | | - }; |
191 | | - let master_gain = fx.master_gain.clone(); |
192 | | - let sat_pre = fx.sat_pre.clone(); |
193 | | - let sat_wet = fx.sat_wet.clone(); |
194 | | - let sat_dry = fx.sat_dry.clone(); |
195 | | - let reverb_in = fx.reverb_in.clone(); |
196 | | - let reverb_wet = fx.reverb_wet.clone(); |
197 | | - let delay_in = fx.delay_in.clone(); |
198 | | - let delay_feedback = fx.delay_feedback.clone(); |
199 | | - let delay_wet = fx.delay_wet.clone(); |
200 | | - |
201 | | - // Per-voice master gains -> master bus, plus effect sends |
202 | | - let initial_positions: Vec<Vec3> = |
203 | | - engine.borrow().voices.iter().map(|v| v.position).collect(); |
204 | | - let routing = match audio::wire_voices( |
205 | | - &audio_ctx, |
206 | | - &initial_positions, |
207 | | - &master_gain, |
208 | | - &delay_in, |
209 | | - &reverb_in, |
210 | | - ) { |
211 | | - Ok(r) => r, |
212 | | - Err(_) => return, |
213 | | - }; |
214 | | - let delay_sends = Rc::new(routing.delay_sends); |
215 | | - let reverb_sends = Rc::new(routing.reverb_sends); |
216 | | - let voice_panners = routing.voice_panners; |
217 | | - let voice_gains = Rc::new(routing.voice_gains); |
218 | | - |
219 | | - // Initialize WebGPU |
220 | | - let gpu: Option<render::GpuState> = frame::init_gpu(&canvas_for_click_inner).await; |
221 | | - |
222 | | - // Visual pulses per voice and optional analyser for ambient effects |
223 | | - let pulses = Rc::new(RefCell::new(vec![0.0_f32; engine.borrow().voices.len()])); |
224 | | - let (analyser, analyser_buf) = audio::create_analyser(&audio_ctx); |
225 | | - |
226 | | - // Queued ripple UV from pointer taps (read by render tick) |
227 | | - let queued_ripple_uv: Rc<RefCell<Option<[f32; 2]>>> = Rc::new(RefCell::new(None)); |
228 | | - |
229 | | - // ---------------- Interaction state ---------------- |
230 | | - let mouse_state = Rc::new(RefCell::new(input::MouseState::default())); |
231 | | - let hover_index = Rc::new(RefCell::new(None::<usize>)); |
232 | | - let drag_state = Rc::new(RefCell::new(input::DragState::default())); |
233 | | - |
234 | | - // Keyboard controls |
235 | | - events::wire_global_keydown( |
236 | | - engine.clone(), |
237 | | - paused.clone(), |
238 | | - master_gain.clone(), |
239 | | - canvas_for_click_inner.clone(), |
240 | | - ); |
241 | | - |
242 | | - // Pointer handlers (move/down/up) |
243 | | - events::wire_input_handlers(events::InputWiring { |
244 | | - canvas: canvas_for_click_inner.clone(), |
245 | | - engine: engine.clone(), |
246 | | - mouse_state: mouse_state.clone(), |
247 | | - hover_index: hover_index.clone(), |
248 | | - drag_state: drag_state.clone(), |
249 | | - voice_gains: voice_gains.clone(), |
250 | | - delay_sends: delay_sends.clone(), |
251 | | - reverb_sends: reverb_sends.clone(), |
252 | | - audio_ctx: audio_ctx.clone(), |
253 | | - queued_ripple_uv: queued_ripple_uv.clone(), |
254 | | - }); |
255 | | - |
256 | | - // Scheduler + renderer loop driven by requestAnimationFrame |
257 | | - let frame_ctx = Rc::new(RefCell::new(frame::FrameContext { |
258 | | - engine: engine.clone(), |
259 | | - paused: paused.clone(), |
260 | | - pulses: pulses.clone(), |
261 | | - hover_index: hover_index.clone(), |
262 | | - canvas: canvas_for_click_inner.clone(), |
263 | | - mouse: mouse_state.clone(), |
264 | | - audio_ctx: audio_ctx.clone(), |
265 | | - listener: listener_for_tick.clone(), |
266 | | - voice_gains: voice_gains.clone(), |
267 | | - delay_sends: delay_sends.clone(), |
268 | | - reverb_sends: reverb_sends.clone(), |
269 | | - voice_panners, |
270 | | - reverb_wet: reverb_wet.clone(), |
271 | | - delay_wet: delay_wet.clone(), |
272 | | - delay_feedback: delay_feedback.clone(), |
273 | | - sat_pre: sat_pre.clone(), |
274 | | - sat_wet: sat_wet.clone(), |
275 | | - sat_dry: sat_dry.clone(), |
276 | | - analyser: analyser.clone(), |
277 | | - analyser_buf: analyser_buf.clone(), |
278 | | - gpu, |
279 | | - queued_ripple_uv: queued_ripple_uv.clone(), |
280 | | - last_instant: Instant::now(), |
281 | | - prev_uv: [0.5, 0.5], |
282 | | - swirl_energy: 0.0, |
283 | | - swirl_pos: [0.5, 0.5], |
284 | | - swirl_vel: [0.0, 0.0], |
285 | | - swirl_initialized: false, |
286 | | - pulse_energy: [0.0, 0.0, 0.0], |
287 | | - })); |
288 | | - // Start RAF loop |
289 | | - frame::start_loop(frame_ctx); |
290 | | - }); |
291 | | - } |
292 | | - } |
293 | | - |
294 | | - Ok(()) |
295 | | -} |
| 22 | +#[cfg(target_arch = "wasm32")] |
| 23 | +pub use wasm_app::start; |
0 commit comments