1use core::f32::consts::TAU;
13use core::time::Duration;
14use std::collections::HashMap;
15
16use mirage_engine::prelude::*;
17use mirage_engine::{MAX_VOICES, ray};
18
19const ROOM_HALF: f32 = 6.0;
20const WALL_THICKNESS: f32 = 0.3;
21const WALL_HEIGHT: f32 = 2.4;
22const PLAY_BOUND: f32 = ROOM_HALF - WALL_THICKNESS - 0.4;
24
25const EYE_HEIGHT: f32 = 1.6;
26const WALK_SPEED: f32 = 4.0;
27const CHASE_BACK: f32 = 6.0;
28const CHASE_UP: f32 = 5.0;
29
30const SOURCE_HEIGHT: f32 = 0.4;
31const SOURCE_HALF: f32 = 0.22;
32const SOURCE_REFERENCE: f32 = 1.5;
35const SOURCE_PICK_RADIUS: f32 = 0.5;
37
38const LISTENER_WIDTH: f32 = 0.4;
40const LISTENER_DEPTH: f32 = 0.3;
41const EAR_SIZE: f32 = 0.14;
43const EAR_OFFSET: f32 = 0.24;
44const FACING_MARKER_SIZE: f32 = 0.22;
46
47const FLOOR_COLOR: Color = Color::rgb(0.14, 0.14, 0.17);
48const WALL_COLOR: Color = Color::rgb(0.22, 0.24, 0.30);
49const SUN_COLOR: Color = Color::rgb(0.85, 0.85, 0.90);
50const LISTENER_COLOR: Color = Color::rgb(0.85, 0.85, 0.75);
51const RIGHT_EAR_COLOR: Color = Color::rgb(0.85, 0.2, 0.2);
54const LEFT_EAR_COLOR: Color = Color::rgb(0.92, 0.92, 0.88);
55const SOURCE_COLORS: [Color; 3] = [
56 Color::rgb(0.85, 0.35, 0.35),
57 Color::rgb(0.35, 0.75, 0.85),
58 Color::rgb(0.85, 0.75, 0.30),
59];
60const RANGE_COLOR: Color = Color::rgba(1.0, 1.0, 1.0, 0.35);
61const REFERENCE_COLOR: Color = Color::rgba(1.0, 0.85, 0.35, 0.5);
63
64const SKY_ZENITH: Color = Color::rgb(0.10, 0.11, 0.16);
65const SKY_HORIZON: Color = Color::rgb(0.20, 0.20, 0.24);
66const SKY_NADIR: Color = Color::rgb(0.06, 0.06, 0.08);
67const SKY_LIGHT: f32 = 0.2;
70
71const MERGE_POS_A: Vec3 = Vec3::new(-4.0, SOURCE_HEIGHT, 4.5);
74const MERGE_POS_B: Vec3 = Vec3::new(4.0, SOURCE_HEIGHT, 4.5);
75const MERGE_GAIN: f32 = 0.5;
76const MERGE_COLOR_A: Color = Color::rgb(0.95, 0.55, 0.15);
77const MERGE_COLOR_B: Color = Color::rgb(0.55, 0.4, 0.85);
78
79const THEME_LOOP_FROM: Duration = Duration::from_secs(130);
83
84const RING_COUNT: u32 = MAX_VOICES as u32 + 8;
87const RING_RADIUS: f32 = 4.6;
89const RING_REFERENCE: f32 = 2.5;
92const RING_GAIN: f32 = 0.35;
95const RING_COLOR: Color = Color::rgb(0.35, 0.75, 0.95);
98
99const ASSET_FILES: [&str; 9] = [
102 "examples/assets/bounce.ogg",
103 "examples/assets/break.ogg",
104 "examples/assets/serve.ogg",
105 "examples/assets/gameover.ogg",
106 "examples/assets/lost.ogg",
107 "examples/assets/win.ogg",
108 "examples/assets/click.ogg",
109 "examples/assets/music.ogg",
110 "examples/assets/menu_music.ogg",
111];
112
113fn main() {
114 run(
115 Config::new("Mirage: sound lab")
116 .with_size(1280, 720)
117 .with_assets(ASSET_FILES),
118 SoundCheck::init,
119 );
120}
121
122#[derive(Catalog, Clone, Copy, Debug, Eq, Hash, PartialEq)]
124enum Sky {
125 Room,
126}
127
128impl Skyboxes for Sky {
129 fn build(&self, _assets: &Assets) -> SkyboxData {
130 match self {
131 Self::Room => {
132 SkyboxData::gradient(SKY_ZENITH, SKY_HORIZON, SKY_NADIR).lit_by(SKY_LIGHT)
133 }
134 }
135 }
136}
137
138#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
141struct Ring;
142
143impl Mesh for Ring {
144 fn build(&self, _: &Assets) -> MeshData {
145 ring_outline()
146 }
147}
148
149#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
151struct Facing;
152
153impl Mesh for Facing {
154 fn build(&self, _: &Assets) -> MeshData {
155 facing_marker()
156 }
157}
158
159meshes! { enum Shape { Plane, Cube, Ring, Sphere, Facing } }
163
164fn ring_outline() -> MeshData {
165 const SEGMENTS: u32 = 48;
166 const OUTER: f32 = 1.0;
167 const INNER: f32 = 0.94;
168
169 let mut vertices = Vec::with_capacity(SEGMENTS as usize * 4);
170 let mut indices = Vec::with_capacity(SEGMENTS as usize * 6);
171 for segment in 0..SEGMENTS {
172 let a0 = segment as f32 / SEGMENTS as f32 * TAU;
173 let a1 = (segment + 1) as f32 / SEGMENTS as f32 * TAU;
174 let (u0, v0) = (a0.cos(), a0.sin());
175 let (u1, v1) = (a1.cos(), a1.sin());
176 let base = vertices.len() as u32;
177 vertices.extend([
178 Vertex::new(Vec3::new(INNER * u0, 0.0, -INNER * v0), Vec3::Y, Vec2::ZERO),
179 Vertex::new(Vec3::new(OUTER * u0, 0.0, -OUTER * v0), Vec3::Y, Vec2::ZERO),
180 Vertex::new(Vec3::new(OUTER * u1, 0.0, -OUTER * v1), Vec3::Y, Vec2::ZERO),
181 Vertex::new(Vec3::new(INNER * u1, 0.0, -INNER * v1), Vec3::Y, Vec2::ZERO),
182 ]);
183 indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
184 }
185 MeshData::new(vertices, indices)
186}
187
188fn facing_marker() -> MeshData {
189 const TIP: Vec3 = Vec3::new(0.0, 0.0, -0.5);
190 const BACK: [Vec3; 4] = [
191 Vec3::new(-0.5, -0.5, 0.5),
192 Vec3::new(0.5, -0.5, 0.5),
193 Vec3::new(0.5, 0.5, 0.5),
194 Vec3::new(-0.5, 0.5, 0.5),
195 ];
196
197 let mut vertices = Vec::with_capacity(BACK.len() * 3);
198 for (corner, next) in BACK.iter().zip(BACK.iter().cycle().skip(1)) {
199 let normal = (next - corner).cross(TIP - corner).normalize();
200 vertices.extend([
201 Vertex::new(*corner, normal, Vec2::new(0.0, 1.0)),
202 Vertex::new(*next, normal, Vec2::new(1.0, 1.0)),
203 Vertex::new(TIP, normal, Vec2::new(0.5, 0.0)),
204 ]);
205 }
206 let indices = (0..vertices.len() as u32).collect();
207 MeshData::new(vertices, indices)
208}
209
210#[derive(Catalog, Clone, Copy, PartialEq, Eq, Hash)]
216enum Sound {
217 Bounce,
218 Break,
219 Serve,
220 GameOver,
221 Lost,
222 Win,
223 Click,
224 Theme,
225 ThemeDecoded,
226 MenuTheme,
227 Pulse,
228}
229
230impl Sound {
231 const ONE_SHOTS: [Sound; 7] = [
233 Sound::Bounce,
234 Sound::Break,
235 Sound::Serve,
236 Sound::GameOver,
237 Sound::Lost,
238 Sound::Win,
239 Sound::Click,
240 ];
241
242 const SOURCE_CHOICES: [Sound; 9] = [
244 Sound::Bounce,
245 Sound::Break,
246 Sound::Serve,
247 Sound::GameOver,
248 Sound::Lost,
249 Sound::Win,
250 Sound::Click,
251 Sound::Theme,
252 Sound::ThemeDecoded,
253 ];
254
255 fn label(self) -> &'static str {
256 match self {
257 Sound::Bounce => "bounce",
258 Sound::Break => "break",
259 Sound::Serve => "serve",
260 Sound::GameOver => "game over",
261 Sound::Lost => "lost",
262 Sound::Win => "win",
263 Sound::Click => "click",
264 Sound::Theme => "theme (streamed)",
265 Sound::ThemeDecoded => "theme (decoded)",
266 Sound::MenuTheme => "menu theme",
267 Sound::Pulse => "pulse",
268 }
269 }
270}
271
272impl Sounds for Sound {
273 fn build(&self, assets: &Assets) -> SoundData {
274 match self {
275 Sound::Bounce => assets.sound("bounce"),
276 Sound::Break => assets.sound("break"),
277 Sound::Serve => assets.sound("serve"),
278 Sound::GameOver => assets.sound("gameover"),
279 Sound::Lost => assets.sound("lost"),
280 Sound::Win => assets.sound("win"),
281 Sound::Click => assets.sound("click"),
282 Sound::Theme => assets.sound("music").streamed(),
283 Sound::ThemeDecoded => assets.sound("music"),
284 Sound::MenuTheme => assets.sound("menu_music").streamed(),
285 Sound::Pulse => assets.sound("break"),
286 }
287 }
288}
289
290#[derive(InputButtonAction, Clone, Copy, PartialEq)]
292enum Button {
293 Select,
294}
295
296impl InputButtonAction for Button {
297 fn bindings(&self) -> Vec<ButtonBinding> {
298 match self {
299 Button::Select => vec![MouseButton::Left.into()],
300 }
301 }
302}
303
304#[derive(InputAxis2Action, Clone, Copy, PartialEq)]
306enum Move {
307 Walk,
308}
309
310impl InputAxis2Action for Move {
311 fn bindings(&self) -> Vec<Axis2Binding> {
312 match self {
313 Move::Walk => vec![
314 Axis2Binding::from(ButtonAxis2 {
315 left: Key::A,
316 right: Key::D,
317 down: Key::S,
318 up: Key::W,
319 }),
320 Axis2Binding::stick(Stick::Left),
321 ],
322 }
323 }
324}
325
326struct Controls;
327
328impl InputActions for Controls {
329 type Button = Button;
330 type Axis = NoInputAxes;
331 type Axis2 = Move;
332}
333
334struct Source {
337 position: Vec3,
338 sound: Sound,
339 gain: f32,
340 reference: f32,
341 range: f32,
342 pitch: f32,
343 enabled: bool,
345}
346
347impl Source {
348 fn new(x: f32, z: f32, sound: Sound, range: f32, enabled: bool) -> Self {
349 Self {
350 position: Vec3::new(x, SOURCE_HEIGHT, z),
351 sound,
352 gain: 0.5,
353 reference: SOURCE_REFERENCE,
354 range,
355 pitch: 1.0,
356 enabled,
357 }
358 }
359
360 fn cue(&self) -> SoundCue<Sound> {
363 let cue = self
364 .sound
365 .at(self.position)
366 .gain(self.gain)
367 .reference(self.reference)
368 .range(self.range)
369 .pitch(self.pitch);
370 match self.sound {
371 Sound::Theme | Sound::ThemeDecoded => cue.loop_from(THEME_LOOP_FROM),
372 _ => cue,
373 }
374 }
375}
376
377struct SoundCheck {
378 master_volume: f32,
379
380 picked: Sound,
381 one_shot_gain: f32,
382 one_shot_pitch: f32,
383 one_shot_fade: f32,
384 trim_start: f32,
385 trim_end: f32,
386 one_shot_loop_from: f32,
387
388 theme_on: bool,
389 menu_on: bool,
390 pulse_on: bool,
391 cue_fade: f32,
392
393 merge_demo: bool,
397
398 ring_demo: bool,
401
402 player: Vec2,
403 player_prev: Vec2,
404 sources: [Source; 3],
405 dragging: Option<usize>,
406
407 durations: HashMap<Sound, Duration>,
409}
410
411impl SoundCheck {
412 fn init(ctx: &mut InitContext<'_, SoundCheck>) -> Result<Self, Error> {
413 let durations = ctx.durations();
414
415 let picked = Sound::Bounce;
416 let trim_end = durations.get(&picked).copied().unwrap_or_default();
417
418 Ok(Self {
419 master_volume: 1.0,
420
421 picked,
422 one_shot_gain: 1.0,
423 one_shot_pitch: 1.0,
424 one_shot_fade: SoundCue::<Sound>::DEFAULT_FADE.as_secs_f32(),
425 trim_start: 0.0,
426 trim_end: trim_end.as_secs_f32(),
427 one_shot_loop_from: 0.0,
428
429 theme_on: false,
430 menu_on: false,
431 pulse_on: false,
432 cue_fade: 1.0,
433
434 merge_demo: false,
435 ring_demo: false,
436
437 player: Vec2::ZERO,
438 player_prev: Vec2::ZERO,
439 sources: [
440 Source::new(-2.5, -2.0, Sound::Bounce, 4.0, false),
441 Source::new(2.5, -2.0, Sound::Serve, 4.0, false),
442 Source::new(0.0, 2.8, Sound::Theme, 7.0, true),
443 ],
444 dragging: None,
445
446 durations,
447 })
448 }
449
450 fn camera(player: Vec2) -> Camera {
451 let ground = Vec3::new(player.x, 0.0, player.y);
452 Camera::new(
453 View::look_at(
454 ground + Vec3::new(0.0, CHASE_UP, CHASE_BACK),
455 ground + Vec3::Y * 0.5,
456 ),
457 Projection::perspective(55.0),
458 )
459 }
460
461 fn handle_walk(&mut self, ctx: &mut TickContext<'_, SoundCheck>) {
462 self.player_prev = self.player;
463 if ctx.ui_wants_keyboard() {
464 return;
465 }
466 let walk = ctx.axis2(Move::Walk);
467 let world = Vec2::new(walk.x, -walk.y);
468 self.player = (self.player + world * WALK_SPEED * ctx.dt().as_secs_f32())
469 .clamp(Vec2::splat(-PLAY_BOUND), Vec2::splat(PLAY_BOUND));
470 }
471
472 fn handle_drag(&mut self, ctx: &mut TickContext<'_, SoundCheck>) {
475 if ctx.released(Button::Select) {
479 self.dragging = None;
480 }
481 if ctx.ui_wants_pointer() {
482 return;
483 }
484 let ray = ctx
485 .last_camera()
486 .ray_through(ctx.pointer(), ctx.window_size());
487
488 if ctx.pressed(Button::Select) {
489 self.dragging = self.sources.iter().position(|source| {
490 ray.hit_sphere(source.position, SOURCE_PICK_RADIUS)
491 .is_some()
492 });
493 }
494
495 let Some(index) = self.dragging else {
496 return;
497 };
498 let Some(distance) = ray.hit_plane(ray::Plane {
499 point: Vec3::ZERO,
500 normal: Vec3::Y,
501 }) else {
502 return;
503 };
504 let hit = ray.at(distance);
505 let dropped =
506 Vec2::new(hit.x, hit.z).clamp(Vec2::splat(-PLAY_BOUND), Vec2::splat(PLAY_BOUND));
507 self.sources[index].position = Vec3::new(dropped.x, SOURCE_HEIGHT, dropped.y);
508 }
509
510 fn draw_room(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
511 ctx.draw(
512 Plane
513 .at(Transform::from_scale(Vec3::new(
514 ROOM_HALF * 2.0,
515 1.0,
516 ROOM_HALF * 2.0,
517 )))
518 .material(Material::lit(FLOOR_COLOR)),
519 );
520
521 let side_half = Vec3::new(WALL_THICKNESS * 0.5, WALL_HEIGHT * 0.5, ROOM_HALF);
522 for side in [-1.0, 1.0] {
523 let x = side * (ROOM_HALF - WALL_THICKNESS * 0.5);
524 ctx.draw(
525 Cube.at(Transform::from_scale_rotation_translation(
526 side_half * 2.0,
527 Quat::IDENTITY,
528 Vec3::new(x, side_half.y, 0.0),
529 ))
530 .material(Material::lit(WALL_COLOR)),
531 );
532 }
533 let end_half = Vec3::new(ROOM_HALF, WALL_HEIGHT * 0.5, WALL_THICKNESS * 0.5);
534 for side in [-1.0, 1.0] {
535 let z = side * (ROOM_HALF - WALL_THICKNESS * 0.5);
536 ctx.draw(
537 Cube.at(Transform::from_scale_rotation_translation(
538 end_half * 2.0,
539 Quat::IDENTITY,
540 Vec3::new(0.0, end_half.y, z),
541 ))
542 .material(Material::lit(WALL_COLOR)),
543 );
544 }
545 }
546
547 fn draw_sources(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
548 for (index, source) in self.sources.iter().enumerate() {
549 let color = SOURCE_COLORS[index];
550 let picked_up = self.dragging == Some(index);
551 let scale = if picked_up { 1.3 } else { 1.0 };
552 let emissive = if source.enabled {
553 Color::rgb(color.red * 3.0, color.green * 3.0, color.blue * 3.0)
554 } else {
555 color.dimmed(0.15)
556 };
557
558 for (radius, ring_color) in [
559 (source.range, RANGE_COLOR),
560 (source.reference, REFERENCE_COLOR),
561 ] {
562 ctx.draw(
563 Ring.at(Transform::from_scale_rotation_translation(
564 Vec3::new(radius, 1.0, radius),
565 Quat::IDENTITY,
566 Vec3::new(source.position.x, 0.01, source.position.z),
567 ))
568 .material(Material::color(ring_color)),
569 );
570 }
571 ctx.draw(
572 Cube.at(Transform::from_scale_rotation_translation(
573 Vec3::splat(SOURCE_HALF * 2.0 * scale),
574 Quat::IDENTITY,
575 source.position,
576 ))
577 .material(Material::shaded(color, 0.6).emissive(emissive)),
578 );
579 }
580 }
581
582 fn draw_listener(&self, ctx: &mut FrameContext<'_, SoundCheck>, view: View) {
586 let head = view.eye();
587 let ground = Vec3::new(head.x, 0.0, head.z);
588
589 ctx.draw(
590 Cube.at(Transform::from_scale_rotation_translation(
591 Vec3::new(LISTENER_WIDTH, head.y, LISTENER_DEPTH),
592 Quat::IDENTITY,
593 ground + Vec3::Y * head.y * 0.5,
594 ))
595 .material(Material::lit(LISTENER_COLOR)),
596 );
597
598 let right = listener_right(view) * EAR_OFFSET;
599 for (offset, color) in [(right, RIGHT_EAR_COLOR), (-right, LEFT_EAR_COLOR)] {
600 ctx.draw(
601 Sphere { subdivisions: 1 }
602 .at(Transform::from_scale_rotation_translation(
603 Vec3::splat(EAR_SIZE),
604 Quat::IDENTITY,
605 head + offset,
606 ))
607 .material(Material::lit(color)),
608 );
609 }
610
611 ctx.draw(
612 Facing
613 .at(Transform::from_scale_rotation_translation(
614 Vec3::splat(FACING_MARKER_SIZE),
615 Quat::IDENTITY,
616 head + Vec3::NEG_Z * (FACING_MARKER_SIZE * 0.5),
617 ))
618 .material(Material::lit(LISTENER_COLOR)),
619 );
620 }
621
622 fn draw_merge_markers(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
623 if !self.merge_demo {
624 return;
625 }
626 for (position, color) in [(MERGE_POS_A, MERGE_COLOR_A), (MERGE_POS_B, MERGE_COLOR_B)] {
627 ctx.draw(
628 Cube.at(Transform::from_scale_rotation_translation(
629 Vec3::splat(SOURCE_HALF * 2.0),
630 Quat::IDENTITY,
631 position,
632 ))
633 .material(Material::lit(color)),
634 );
635 }
636 }
637
638 fn draw_ring(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
641 if !self.ring_demo {
642 return;
643 }
644 for nth in 0..RING_COUNT {
645 let over = 1.0 - nth as f32 / RING_COUNT as f32;
646 ctx.draw(
647 Cube.at(Transform::from_scale_rotation_translation(
648 Vec3::splat(SOURCE_HALF),
649 Quat::IDENTITY,
650 ring_place(nth),
651 ))
652 .material(Material::lit(RING_COLOR.dimmed(over))),
653 );
654 }
655 }
656
657 fn side_panel(&mut self, ctx: &mut FrameContext<'_, SoundCheck>) {
660 #[cfg(target_arch = "wasm32")]
661 let unlocked = ctx.sound_unlocked();
662
663 let master_volume = &mut self.master_volume;
664 let theme_on = &mut self.theme_on;
665 let menu_on = &mut self.menu_on;
666 let pulse_on = &mut self.pulse_on;
667 let cue_fade = &mut self.cue_fade;
668 let merge_demo = &mut self.merge_demo;
669 let ring_demo = &mut self.ring_demo;
670 let ring_label = format!("cap demo: sustain {RING_COUNT} sounds at once");
671 let ring_note = format!(
672 "each one is quieter than the one before it, so the engine plays the loudest {MAX_VOICES} and the rest go silent without stopping"
673 );
674 let sources = &mut self.sources;
675
676 ctx.ui(|ui| {
677 egui::Panel::left("controls").show(ui, |ui| {
678 egui::ScrollArea::vertical()
679 .auto_shrink([false, false])
680 .show(ui, |ui| {
681 ui.heading("master");
682 ui.add(egui::Slider::new(master_volume, 0.0..=1.5).text("volume"));
683 #[cfg(target_arch = "wasm32")]
684 if !unlocked {
685 ui.label("audio unlocks on the first click or key in the browser");
686 }
687
688 ui.separator();
689 ui.heading("cue lab");
690 ui.label("a checked box is the sustain declaration");
691 ui.label("unchecking fades it out and parks it");
692 ui.checkbox(theme_on, Sound::Theme.label());
693 ui.checkbox(menu_on, Sound::MenuTheme.label());
694 ui.checkbox(pulse_on, Sound::Pulse.label());
695 ui.add(egui::Slider::new(cue_fade, 0.0..=3.0).text("fade (seconds)"));
696
697 ui.separator();
698 ui.heading("spatial lab");
699 ui.label("drag a source's marker on the floor to move it");
700 ui.label(
701 "a source is at full level inside its gold ring and falls to nothing at the white one",
702 );
703 ui.label("red is the right ear (RCA convention), white is the left");
704 ui.label("the point on the listener always faces -Z");
705 for (index, source) in sources.iter_mut().enumerate() {
706 ui.push_id(index, |ui| {
707 ui.separator();
708 ui.label(format!("source {}", index + 1));
709 ui.checkbox(&mut source.enabled, "enabled");
710 egui::ComboBox::from_label("clip")
711 .selected_text(source.sound.label())
712 .show_ui(ui, |ui| {
713 for choice in Sound::SOURCE_CHOICES {
714 ui.selectable_value(
715 &mut source.sound,
716 choice,
717 choice.label(),
718 );
719 }
720 });
721 ui.add(egui::Slider::new(&mut source.gain, 0.0..=2.0).text("gain"));
722 ui.add(
723 egui::Slider::new(&mut source.range, 1.0..=12.0).text("range"),
724 );
725 let range = source.range;
726 ui.add(
727 egui::Slider::new(&mut source.reference, 0.25..=range)
728 .text("reference"),
729 );
730 ui.add(
731 egui::Slider::new(&mut source.pitch, 0.5..=2.0).text("pitch"),
732 );
733 });
734 }
735 ui.separator();
736 ui.label(
737 "each enabled source above sustains at its own instance (0, 1, 2 by position), so the same clip can play at every one without merging into one voice",
738 );
739 ui.checkbox(merge_demo, "merge demo: same clip, both at instance 0");
740 ui.label(
741 "both declarations below target the same clip at the default instance",
742 );
743 ui.label(
744 "only the one declared last is heard, proof of what the sources above avoid",
745 );
746 ui.separator();
747 ui.checkbox(ring_demo, &ring_label);
748 ui.label(&ring_note);
749 ui.label(
750 "walk into the ring, or turn a source up, and what is played changes with what is loudest",
751 );
752 });
753 });
754 });
755 }
756
757 fn one_shot_panel(&mut self, ctx: &mut FrameContext<'_, SoundCheck>) -> (bool, bool) {
761 let mut play_once = false;
762 let mut play_many = false;
763 let durations = &self.durations;
764 let picked = &mut self.picked;
765 let gain = &mut self.one_shot_gain;
766 let pitch = &mut self.one_shot_pitch;
767 let fade = &mut self.one_shot_fade;
768 let trim_start = &mut self.trim_start;
769 let trim_end = &mut self.trim_end;
770 let loop_from = &mut self.one_shot_loop_from;
771 let duration = durations
772 .get(picked)
773 .copied()
774 .unwrap_or_default()
775 .as_secs_f32()
776 .max(0.001);
777
778 ctx.ui(|ui| {
779 egui::Panel::bottom("one-shot").show(ui, |ui| {
780 ui.heading("one-shot lab");
781 egui::ComboBox::from_label("clip")
782 .selected_text(picked.label())
783 .show_ui(ui, |ui| {
784 for choice in Sound::ONE_SHOTS {
785 if ui
786 .selectable_label(*picked == choice, choice.label())
787 .clicked()
788 && *picked != choice
789 {
790 *picked = choice;
791 *trim_start = 0.0;
792 *trim_end = durations
793 .get(&choice)
794 .copied()
795 .unwrap_or_default()
796 .as_secs_f32();
797 *loop_from = 0.0;
798 }
799 }
800 });
801
802 ui.add(egui::Slider::new(gain, 0.0..=2.0).text("gain"));
803 ui.add(egui::Slider::new(pitch, 0.5..=2.0).text("pitch"));
804 ui.add(egui::Slider::new(fade, 0.0..=2.0).text("fade (seconds)"));
805
806 duration_bar(ui, duration, trim_start, trim_end, loop_from);
807 ui.label(
808 "the marker sets loop_from, which a one-shot ignores: only sustain reads it",
809 );
810
811 ui.horizontal(|ui| {
812 play_once = ui.button("play").clicked();
813 play_many = ui.button("play ×32 (overruns the voice cap)").clicked();
814 });
815 });
816 });
817
818 (play_once, play_many)
819 }
820
821 fn one_shot_cue(&self) -> SoundCue<Sound> {
822 self.picked
823 .gain(self.one_shot_gain)
824 .pitch(self.one_shot_pitch)
825 .fade(Duration::from_secs_f32(self.one_shot_fade))
826 .trim_to(
827 Duration::from_secs_f32(self.trim_start),
828 Duration::from_secs_f32(self.trim_end),
829 )
830 .loop_from(Duration::from_secs_f32(self.one_shot_loop_from))
831 }
832
833 fn sustain_ring(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
837 if !self.ring_demo {
838 return;
839 }
840 for nth in 0..RING_COUNT {
841 let gain = RING_GAIN * (1.0 - nth as f32 / RING_COUNT as f32);
842 ctx.sustain(
843 Sound::Pulse
844 .at(ring_place(nth))
845 .gain(gain)
846 .reference(RING_REFERENCE)
847 .range(RING_RADIUS * 3.0)
848 .instance(nth + 1),
849 );
850 }
851 }
852
853 fn sustain_cues(&self, ctx: &mut FrameContext<'_, SoundCheck>) {
854 let fade = Duration::from_secs_f32(self.cue_fade);
855 if self.theme_on {
856 ctx.sustain(Sound::Theme.gain(0.5).fade(fade));
857 }
858 if self.menu_on {
859 ctx.sustain(Sound::MenuTheme.gain(0.5).fade(fade));
860 }
861 if self.pulse_on {
862 ctx.sustain(Sound::Pulse.gain(0.3).fade(fade));
863 }
864 }
865}
866
867fn ring_place(nth: u32) -> Vec3 {
870 let turn = TAU * nth as f32 / RING_COUNT as f32;
871
872 Vec3::new(
873 turn.sin() * RING_RADIUS,
874 SOURCE_HEIGHT,
875 turn.cos() * RING_RADIUS,
876 )
877}
878
879fn listener_right(view: View) -> Vec3 {
882 (view.target() - view.eye())
883 .normalize_or_zero()
884 .cross(view.up())
885}
886
887fn duration_bar(
890 ui: &mut egui::Ui,
891 duration: f32,
892 trim_start: &mut f32,
893 trim_end: &mut f32,
894 loop_from: &mut f32,
895) {
896 let size = egui::vec2(ui.available_width().min(420.0), 28.0);
897 let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
898 let painter = ui.painter();
899 painter.rect_filled(rect, 3.0, egui::Color32::from_gray(35));
900
901 let x_of = |seconds: f32| rect.left() + (seconds / duration).clamp(0.0, 1.0) * rect.width();
902 let seconds_of = |x: f32| ((x - rect.left()) / rect.width()).clamp(0.0, 1.0) * duration;
903
904 let span = egui::Rect::from_min_max(
905 egui::pos2(x_of(*trim_start), rect.top()),
906 egui::pos2(x_of(*trim_end), rect.bottom()),
907 );
908 painter.rect_filled(span, 3.0, egui::Color32::from_rgb(70, 120, 95));
909
910 let start_x = x_of(*trim_start);
911 if let Some(x) = drag_handle(
912 ui,
913 rect,
914 "trim-start",
915 start_x,
916 egui::Color32::from_rgb(230, 200, 80),
917 ) {
918 *trim_start = seconds_of(x).min(*trim_end);
919 }
920 let end_x = x_of(*trim_end);
921 if let Some(x) = drag_handle(
922 ui,
923 rect,
924 "trim-end",
925 end_x,
926 egui::Color32::from_rgb(230, 200, 80),
927 ) {
928 *trim_end = seconds_of(x).max(*trim_start);
929 }
930 let loop_x = x_of(*loop_from);
931 if let Some(x) = drag_handle(
932 ui,
933 rect,
934 "loop-from",
935 loop_x,
936 egui::Color32::from_rgb(90, 170, 230),
937 ) {
938 *loop_from = seconds_of(x).clamp(*trim_start, *trim_end);
939 }
940}
941
942fn drag_handle(
945 ui: &mut egui::Ui,
946 bar: egui::Rect,
947 salt: &str,
948 x: f32,
949 color: egui::Color32,
950) -> Option<f32> {
951 let radius = 6.0;
952 let center = egui::pos2(x, bar.center().y);
953 let sense_rect = egui::Rect::from_center_size(center, egui::Vec2::splat(radius * 2.5));
954 let id = ui.id().with(salt);
955 let response = ui.interact(sense_rect, id, egui::Sense::drag());
956 ui.painter().circle_filled(center, radius, color);
957
958 response
959 .dragged()
960 .then(|| response.interact_pointer_pos())
961 .flatten()
962 .map(|pos| pos.x)
963}
964
965impl Game for SoundCheck {
966 type Meshes = Shape;
967 type Sounds = Sound;
968 type InputActions = Controls;
969 type Skyboxes = Sky;
970 type SurfaceStyles = ();
971 type PostEffects = ();
972
973 fn tick(&mut self, ctx: &mut TickContext<'_, SoundCheck>) {
974 self.handle_walk(ctx);
975 self.handle_drag(ctx);
976 }
977
978 fn frame(&mut self, ctx: &mut FrameContext<'_, SoundCheck>) {
979 ctx.set_volume(self.master_volume);
980
981 let player = self.player_prev.lerp(self.player, ctx.alpha());
982 let ear = Vec3::new(player.x, EYE_HEIGHT, player.y);
983 let listener = View::look_at(ear, ear + Vec3::NEG_Z);
984 ctx.set_listener(listener);
985
986 ctx.set_camera(Self::camera(player));
987 ctx.set_skybox(Sky::Room);
988 ctx.set_bloom(0.2);
989 ctx.light(Light::directional(Vec3::new(-0.4, -1.0, -0.5), SUN_COLOR).shadow());
990
991 self.draw_room(ctx);
992 self.draw_sources(ctx);
993 self.draw_listener(ctx, listener);
994 self.draw_merge_markers(ctx);
995 self.draw_ring(ctx);
996
997 self.sustain_cues(ctx);
998 for (index, source) in self.sources.iter().enumerate() {
999 if source.enabled {
1000 ctx.sustain(source.cue().instance(index as u32));
1001 }
1002 }
1003 if self.merge_demo {
1004 ctx.sustain(Sound::Click.at(MERGE_POS_A).gain(MERGE_GAIN));
1005 ctx.sustain(Sound::Click.at(MERGE_POS_B).gain(MERGE_GAIN));
1006 }
1007 self.sustain_ring(ctx);
1008
1009 self.side_panel(ctx);
1010 let (play_once, play_many) = self.one_shot_panel(ctx);
1011
1012 if play_once {
1013 ctx.play(self.one_shot_cue());
1014 }
1015 if play_many {
1016 for _ in 0..32 {
1017 ctx.play(self.one_shot_cue());
1018 }
1019 }
1020 }
1021}