Skip to main content

sound_lab/
sound-lab.rs

1//! A session, not a game — the same kind as `stress-preview.rs`, over
2//! every public sound knob. A small room holds three sources a drag moves
3//! and a listener a walk moves, its marked ears and facing marker matching
4//! what the engine computes for pan. A source's cube is glowing while its
5//! cue sustains, dim while it does not. The left holds the master volume,
6//! sustained cues, and each source's own controls; the bottom holds
7//! one-shot playback.
8//!
9//! `cargo run --example sound-lab`. `WASD` or a stick walks the listener;
10//! hold the left mouse button over a source's cube and move it.
11
12use 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;
22/// Where a source or the listener may be placed, clear of the walls.
23const 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;
32/// Distance a source holds its full level within until a slider moves it: a
33/// meter and a half, so a source across the room is still heard.
34const SOURCE_REFERENCE: f32 = 1.5;
35/// A source's cube counts as hit within this many meters of a click's ray.
36const SOURCE_PICK_RADIUS: f32 = 0.5;
37
38/// The listener's footprint, from the ground up to [`EYE_HEIGHT`].
39const LISTENER_WIDTH: f32 = 0.4;
40const LISTENER_DEPTH: f32 = 0.3;
41/// Ear size, and their offset from the listener along its right.
42const EAR_SIZE: f32 = 0.14;
43const EAR_OFFSET: f32 = 0.24;
44/// The facing marker's size, set with its point at the listener.
45const 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);
51/// Red on the right, white on the left: the pair the engine's own pan
52/// reads, set on the ears so the sides are distinct.
53const 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);
61/// The inner ring drawn around a source, where its level stops being full.
62const 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);
67/// The fraction of its own light the sky lands and reflects: dim, so the
68/// cubes' own glow and the room's light still read.
69const SKY_LIGHT: f32 = 0.2;
70
71/// Two fixed positions holding the same clip at the default instance,
72/// read only while [`SoundCheck::merge_demo`] is set.
73const 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
79/// Where [`Sound::Theme`] and [`Sound::ThemeDecoded`] loop from once
80/// declared: a few seconds short of the end, so a wrap seeks across most of
81/// the clip.
82const THEME_LOOP_FROM: Duration = Duration::from_secs(130);
83
84/// Count of sustains the ring declares at once: more than the engine plays,
85/// so the least loud of them hold no voice.
86const RING_COUNT: u32 = MAX_VOICES as u32 + 8;
87/// Radius of the ring they stand on.
88const RING_RADIUS: f32 = 4.6;
89/// Distance the ring's sustains hold their full level within: over half the
90/// radius, so the whole ring is heard from the middle of the room.
91const RING_REFERENCE: f32 = 2.5;
92/// Gain the loudest of them takes; each one after it is less loud, so the cut
93/// falls inside the ring.
94const RING_GAIN: f32 = 0.35;
95/// Color of the ring's cubes, taken less bright the less loud the sustain a
96/// cube stands for.
97const RING_COLOR: Color = Color::rgb(0.35, 0.75, 0.95);
98
99/// Every source this example loads, next to `index.html` on the web and
100/// under the working directory on the desktop.
101const 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/// The one sky this room draws, a gradient set each frame.
123#[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/// A source's reference or its range: a flat ring on the ground,
139/// unit-sized, drawn that many meters across by its scale.
140#[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/// The listener's facing marker: a point through `-Z`, unit-sized.
150#[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
159// Everything else this game draws is a built-in primitive, given its
160// color and placed per draw: the room's floor and walls, a cube drawn for
161// a source or the listener, and the listener's ears.
162meshes! { 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/// Every sound this game plays. [`Sound::Break`] and [`Sound::Pulse`] read the
211/// same source under two names, so sustaining one and playing the other
212/// once never share a voice; [`Sound::Theme`] and [`Sound::ThemeDecoded`] do
213/// the same for the streamed side against the decoded one, since a clip
214/// decodes one way or the other for good, once built.
215#[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    /// The alternatives a one-shot play offers.
232    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    /// The alternatives one source's sustain offers.
243    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/// The one button this game reads: it holds a source down and moves it.
291#[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/// The listener's walk, in the ground plane.
305#[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
334/// One source a drag moves: a cube on the ground, playing a sustained clip
335/// with its own gain, reference, range, and pitch.
336struct Source {
337    position: Vec3,
338    sound: Sound,
339    gain: f32,
340    reference: f32,
341    range: f32,
342    pitch: f32,
343    /// Whether this source sustains at all; off keeps startup silent.
344    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    /// This source's sustained cue, with the loop point that seeks far
361    /// where its choice needs one.
362    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    /// Sustains [`Sound::Click`] at [`MERGE_POS_A`] and [`MERGE_POS_B`]
394    /// both at the default instance: shows the merge each source's own
395    /// instance above keeps clear of.
396    merge_demo: bool,
397
398    /// Declares [`RING_COUNT`] sustains at once, more than the engine
399    /// plays, so that the cap is heard as it allocates by level.
400    ring_demo: bool,
401
402    player: Vec2,
403    player_prev: Vec2,
404    sources: [Source; 3],
405    dragging: Option<usize>,
406
407    /// Every catalog value's length, read once at startup.
408    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    /// Takes hold of the source a click's ray intersects, moves it across
473    /// the floor while the button stays down, and frees it on release.
474    fn handle_drag(&mut self, ctx: &mut TickContext<'_, SoundCheck>) {
475        // Read before the check below for the UI's own claim on the
476        // pointer, so a release over it still frees a source a drag moved
477        // there.
478        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    /// The listener: a cube drawn from the ground up to [`EYE_HEIGHT`],
583    /// an ear pair set on ± `view`'s right, and a marker at the front that
584    /// shows its fixed `-Z` facing.
585    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    /// Draws the ring, each cube as dim as the gain its sustain is declared
639    /// at.
640    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    /// The controls held at the left: master volume, sustained cues, and
658    /// each source's own knobs.
659    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    /// Reads what the one-shot controls hold, returning whether `play` and
758    /// `play x32` were pressed this frame — read inside the closure, applied
759    /// after it, since the closure cannot borrow `ctx`.
760    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    /// Declares [`RING_COUNT`] sustains on a ring, each less loud than the
834    /// one before it, so the engine's cap plays the loudest of them and the
835    /// rest hold no voice while their playback goes on.
836    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
867/// Where the `nth` sustain of the ring stands: around the room, starting
868/// behind the listener's back.
869fn 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
879/// The direction the ear pair is offset along — the same side the
880/// engine's own pan reads.
881fn listener_right(view: View) -> Vec3 {
882    (view.target() - view.eye())
883        .normalize_or_zero()
884        .cross(view.up())
885}
886
887/// The clip's duration, with two trim handles and a loop marker, each moved
888/// by the pointer's own place rather than by a moving total.
889fn 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
942/// One round handle at `x`. Returns the pointer's `x` while a drag holds
943/// it.
944fn 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}