Skip to main content

SoundCue

Struct SoundCue 

Source
pub struct SoundCue<S: Sounds> { /* private fields */ }
Expand description

A sound and how to play it.

Every knob has a default, so a bare vocabulary value plays as it was loaded.

Implementations§

Source§

impl<S: Sounds> SoundCue<S>

Source

pub const DEFAULT_RANGE: f32

Range a placed sound is heard at when no call sets it: 50 meters.

Source

pub const DEFAULT_REFERENCE: f32

Distance a placed sound holds its full level within when no call sets it: 1 meter.

Source

pub const DEFAULT_FADE: Duration

Fade a voice takes to come up and go down when no call sets it: as short as it can be without a click.

Source

pub const DEFAULT_GLIDE: Duration = GLIDE

Span a change of gain or position slides over when no call sets it: short enough to follow a moving source, long enough not to click.

Source

pub fn gain(self, gain: f32) -> Self

Plays at gain, a fraction of the level the sound was loaded at; 1.0 as loaded, never less than nothing.

Examples found in repository?
examples/sound-lab.rs (line 366)
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 = NoSurfaceStyles;
971    type PostEffects = NoPostEffects;
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    }
Source

pub fn pitch(self, pitch: f32) -> Self

Plays at pitch, a fraction of the rate the sound was loaded at, which moves pitch and speed together; clamped to never go below 0.01 of that rate.

Examples found in repository?
examples/sound-lab.rs (line 369)
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    }
Source

pub fn at(self, position: Vec3) -> Self

Places the sound at position, which pans it and fades it with distance from the listener.

A sound with no position is heard the same in both ears, however the listener moves.

Source

pub fn range(self, range: f32) -> Self

Fades a placed sound to nothing range meters from the listener; SoundCue::DEFAULT_RANGE until set.

The reference never lies past the range, so a smaller range holds it there.

Examples found in repository?
examples/sound-lab.rs (line 368)
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    }
Source

pub fn reference(self, reference: f32) -> Self

Holds a placed sound at full level within reference meters of the listener, past which it falls by the inverse of the distance to nothing at the range; SoundCue::DEFAULT_REFERENCE until set.

A reference past the range is held at the range. One at or under zero is held just above zero.

Examples found in repository?
examples/sound-lab.rs (line 367)
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    }
More examples
Hide additional examples
examples/breakout-game.rs (line 522)
485    fn bounce_bricks(&mut self, ctx: &mut TickContext<'_, Breakout>) {
486        let reach_x = BRICK_HALF_WIDTH + BALL_RADIUS;
487        let reach_z = BRICK_HALF_DEPTH + BALL_RADIUS;
488        let mut broken = None;
489
490        for brick in self
491            .bricks
492            .iter_mut()
493            .filter(|brick| brick.hits_remaining > 0)
494        {
495            let dx = self.ball_pos.x - brick.position.x;
496            let dz = self.ball_pos.z - brick.position.z;
497            if dx.abs() > reach_x || dz.abs() > reach_z {
498                continue;
499            }
500
501            if reach_x - dx.abs() < reach_z - dz.abs() {
502                self.ball_vel.x = if dx < 0.0 {
503                    -self.ball_vel.x.abs()
504                } else {
505                    self.ball_vel.x.abs()
506                };
507            } else {
508                self.ball_vel.z = if dz < 0.0 {
509                    -self.ball_vel.z.abs()
510                } else {
511                    self.ball_vel.z.abs()
512                };
513            }
514
515            brick.hits_remaining -= 1;
516            self.score += 10 * (BRICK_ROWS - brick.row) as u32;
517            ctx.play(Sound::Bounce.pitch(BRICK_BOUNCE_PITCH));
518            if brick.hits_remaining == 0 {
519                ctx.play(
520                    Sound::BrickBreak
521                        .at(brick.position)
522                        .reference(BRICK_BREAK_REFERENCE),
523                );
524                self.brick_flash = BRICK_FLASH;
525                broken = Some((brick.position, BRICK_ROW_COLORS[brick.row]));
526            }
527            break;
528        }
529
530        if let Some((position, color)) = broken {
531            self.spawn_sparks(position, color);
532        }
533
534        if self.bricks.iter().all(|brick| brick.hits_remaining == 0) {
535            self.phase = Phase::Won;
536            ctx.play(Sound::LevelClear);
537        }
538    }
Source

pub fn fade(self, fade: Duration) -> Self

Takes fade to come up at the start and to go down when a sustain ends; SoundCue::DEFAULT_FADE until set.

A loop wrap is never faded.

Examples found in repository?
examples/sound-lab.rs (line 825)
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    }
More examples
Hide additional examples
examples/breakout-game.rs (line 882)
872    fn sustain_music(&self, ctx: &mut FrameContext<'_, Breakout>) {
873        let playing = !self.paused && matches!(self.phase, Phase::Serving | Phase::Playing);
874        let gain = |wanted: bool| match wanted {
875            true => MUSIC_GAIN,
876            false => 0.0,
877        };
878
879        ctx.sustain(
880            Sound::Music
881                .gain(gain(playing))
882                .fade(MUSIC_CROSSFADE)
883                .glide(MUSIC_CROSSFADE)
884                .loop_from(MUSIC_LOOP_FROM),
885        );
886        ctx.sustain(
887            Sound::MenuMusic
888                .gain(gain(!playing))
889                .fade(MUSIC_CROSSFADE)
890                .glide(MUSIC_CROSSFADE)
891                .loop_from(MENU_MUSIC_LOOP_FROM),
892        );
893    }
Source

pub fn glide(self, glide: Duration) -> Self

Slides over glide every later change of gain or position; SoundCue::DEFAULT_GLIDE until set.

A crossfade over seconds states its span here: the gain a frame declares is the level the voice slides to, never a step.

Examples found in repository?
examples/breakout-game.rs (line 883)
872    fn sustain_music(&self, ctx: &mut FrameContext<'_, Breakout>) {
873        let playing = !self.paused && matches!(self.phase, Phase::Serving | Phase::Playing);
874        let gain = |wanted: bool| match wanted {
875            true => MUSIC_GAIN,
876            false => 0.0,
877        };
878
879        ctx.sustain(
880            Sound::Music
881                .gain(gain(playing))
882                .fade(MUSIC_CROSSFADE)
883                .glide(MUSIC_CROSSFADE)
884                .loop_from(MUSIC_LOOP_FROM),
885        );
886        ctx.sustain(
887            Sound::MenuMusic
888                .gain(gain(!playing))
889                .fade(MUSIC_CROSSFADE)
890                .glide(MUSIC_CROSSFADE)
891                .loop_from(MENU_MUSIC_LOOP_FROM),
892        );
893    }
Source

pub fn trim_to(self, start: Duration, end: Duration) -> Self

Plays only what lies between start and end of the sound’s own timeline.

Both clamp to the sound; a window with nothing in it plays nothing, with a debug log.

Examples found in repository?
examples/sound-lab.rs (lines 826-829)
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    }
Source

pub fn loop_from(self, at: Duration) -> Self

Comes back to at every time a sustain plays to the end of its window, so that what lies before it is heard once.

In the sound’s own timeline, clamped into the window with a debug log. A one-shot never loops, so play ignores this with a debug log.

Examples found in repository?
examples/sound-lab.rs (line 371)
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    }
More examples
Hide additional examples
examples/breakout-game.rs (line 884)
872    fn sustain_music(&self, ctx: &mut FrameContext<'_, Breakout>) {
873        let playing = !self.paused && matches!(self.phase, Phase::Serving | Phase::Playing);
874        let gain = |wanted: bool| match wanted {
875            true => MUSIC_GAIN,
876            false => 0.0,
877        };
878
879        ctx.sustain(
880            Sound::Music
881                .gain(gain(playing))
882                .fade(MUSIC_CROSSFADE)
883                .glide(MUSIC_CROSSFADE)
884                .loop_from(MUSIC_LOOP_FROM),
885        );
886        ctx.sustain(
887            Sound::MenuMusic
888                .gain(gain(!playing))
889                .fade(MUSIC_CROSSFADE)
890                .glide(MUSIC_CROSSFADE)
891                .loop_from(MENU_MUSIC_LOOP_FROM),
892        );
893    }
Source

pub fn instance(self, nth: u32) -> Self

Sustains the sound as the nth of the places it sounds at, so one value is heard at more than one place at once; 0 until set.

A sustain is kept alive by its value and its instance together, and each instance is a voice with knobs of its own. A one-shot is a voice of its own already, so play ignores this with a debug log.

Examples found in repository?
examples/sound-lab.rs (line 848)
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 = NoSurfaceStyles;
971    type PostEffects = NoPostEffects;
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    }

Trait Implementations§

Source§

impl<S: Sounds> Clone for SoundCue<S>

Source§

fn clone(&self) -> Self

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl<S: Debug + Sounds> Debug for SoundCue<S>

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<S: Sounds> From<S> for SoundCue<S>

Source§

fn from(sound: S) -> Self

Converts to this type from the input type.

Auto Trait Implementations§

§

impl<S> Freeze for SoundCue<S>
where S: Freeze,

§

impl<S> RefUnwindSafe for SoundCue<S>
where S: RefUnwindSafe,

§

impl<S> Send for SoundCue<S>
where S: Send,

§

impl<S> Sync for SoundCue<S>
where S: Sync,

§

impl<S> Unpin for SoundCue<S>
where S: Unpin,

§

impl<S> UnsafeUnpin for SoundCue<S>
where S: UnsafeUnpin,

§

impl<S> UnwindSafe for SoundCue<S>
where S: UnwindSafe,

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> Downcast for T
where T: Any,

Source§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
Source§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
Source§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
Source§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
Source§

impl<T> Downcast<T> for T

Source§

fn downcast(&self) -> &T

Source§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

Source§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Sync + Send>

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<S, T> Duplex<S> for T
where T: FromSample<S> + ToSample<S>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<S> FromSample<S> for S

Source§

fn from_sample_(s: S) -> S

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Pointable for T

Source§

const ALIGN: usize

The alignment of pointer.
Source§

type Init = T

The type for initializers.
Source§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
Source§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
Source§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
Source§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> SerializableAny for T
where T: 'static + Any + Clone + for<'a> Send + Sync,

Source§

impl<T, S> SimdFrom<T, S> for T
where S: Simd,

Source§

fn simd_from(_simd: S, value: T) -> T

Source§

impl<F, T, S> SimdInto<T, S> for F
where T: SimdFrom<F, S>, S: Simd,

Source§

fn simd_into(self, simd: S) -> T

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> ToSample<U> for T
where U: FromSample<T>,

Source§

fn to_sample_(self) -> U

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> Upcast<T> for T

Source§

fn upcast(&self) -> Option<&T>

Source§

impl<T> WasmNotSend for T
where T: Send,

Source§

impl<T> WasmNotSendSync for T

Source§

impl<T> WasmNotSync for T
where T: Sync,

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more