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>
impl<S: Sounds> SoundCue<S>
Sourcepub const DEFAULT_RANGE: f32
pub const DEFAULT_RANGE: f32
Range a placed sound is heard at when no call sets it: 50 meters.
Sourcepub const DEFAULT_REFERENCE: f32
pub const DEFAULT_REFERENCE: f32
Distance a placed sound holds its full level within when no call sets
it: 1 meter.
Sourcepub const DEFAULT_FADE: Duration
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.
Sourcepub const DEFAULT_GLIDE: Duration = GLIDE
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.
Sourcepub fn gain(self, gain: f32) -> Self
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?
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 }Sourcepub fn pitch(self, pitch: f32) -> Self
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?
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 }Sourcepub fn at(self, position: Vec3) -> Self
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.
Sourcepub fn range(self, range: f32) -> Self
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?
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 }Sourcepub fn reference(self, reference: f32) -> Self
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?
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
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 }Sourcepub fn fade(self, fade: Duration) -> Self
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?
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
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 }Sourcepub fn glide(self, glide: Duration) -> Self
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?
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 }Sourcepub fn trim_to(self, start: Duration, end: Duration) -> Self
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.
Sourcepub fn loop_from(self, at: Duration) -> Self
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?
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
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 }Sourcepub fn instance(self, nth: u32) -> Self
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?
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§
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> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
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>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
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)
fn as_any(&self) -> &(dyn Any + 'static)
&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)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&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> DowncastSync for T
impl<T> DowncastSync for T
impl<S, T> Duplex<S> for Twhere
T: FromSample<S> + ToSample<S>,
Source§impl<S> FromSample<S> for S
impl<S> FromSample<S> for S
fn from_sample_(s: S) -> S
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
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