Positional audio: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: live (line 84), hear (line 113), scene (line 159), play (line 163), move (line 170).
1/// A sound placed somewhere in the world, heard louder or softer and panned
2/// left or right depending on where the listener stands and faces.
3///
4/// A `SilentBackend` runs this page: it makes no sound and records every
5/// voice it is asked for, which is what lets the numbers below be checked
6/// without a speaker or a browser's audio stack.
7///
8/// Quoted by `positional_audio.md` and shown whole in the Source tab.
9library;
11import 'dart:math' as math;
13import 'package:flutter3d/flutter3d.dart';
14import 'package:flutter3d_audio/flutter3d_audio.dart';
15import 'package:flutter3d_showcase/src/demo/demo.dart';
16import 'package:flutter3d_showcase/src/demo/scene_kit.dart';
17import 'package:vector_math/vector_math.dart';
19final class PositionalAudioDemo extends ShowcaseDemo {
20 late final String _report;
22 double distance = 4.0;
23 double angle = 0.0;
24 bool orbiting = true;
25 bool turnedAway = false;
27 late final SoundEmitter _emitter;
28 late final AudioScene _audio;
29 late final AudioListener _listener;
30 late final MeshNode _source;
31 late final MeshNode _head;
32 late final BarGauge _gain;
33 late final RailGauge _pan;
36 void configureView(DemoContext context) {
37 context.orbit
38 ..distance = 13.0
39 ..pitch = 0.95
40 ..yaw = 0.0;
41 context.orbit.target.setValues(0.0, 0.0, 1.5);
42 }
45 Scene build(DemoContext context) {
46 _report = _run();
47 _source = ballNode(
48 context,
49 'source',
50 0.5,
51 Vector4(0.9, 0.75, 0.3, 1.0),
52 at: Vector3(0.0, 0.5, 0.0),
53 );
54 _head = blockNode(
55 context,
56 'listener',
57 Vector3(0.7, 0.7, 0.7),
58 Vector4(0.45, 0.65, 0.95, 1.0),
59 );
60 // A nose, so it is clear which way the listener faces.
61 _head.add(
62 blockNode(
63 context,
64 'nose',
65 Vector3(0.2, 0.2, 0.4),
66 Vector4(0.95, 0.95, 0.98, 1.0),
67 at: Vector3(0.0, 0.0, -0.5),
68 ),
69 );
70 _gain = BarGauge(
71 context,
72 'gain',
73 Vector4(0.9, 0.75, 0.3, 1.0),
74 Vector3(-3.0, 0.0, 6.0),
75 height: 6.0,
76 );
77 _pan = RailGauge(
78 context,
79 'pan',
80 Vector4(0.45, 0.65, 0.95, 1.0),
81 Vector3(0.0, 0.0, 7.2),
82 );
84 // The same scene as above, asked again every frame: the listener moves
85 // and turns, and what it would hear is read back off the emitter.
86 _audio = AudioScene(backend: SilentBackend());
87 _emitter = _audio.play(
88 const SoundDef(name: 'bell', asset: 'bell.wav'),
89 Vector3(0.0, 0.0, 0.0),
90 );
91 _listener = AudioListener();
92 return sceneOf(<SceneNode>[
93 floorNode(context, width: 18.0, depth: 18.0),
94 _source,
95 _head,
96 ..._gain.nodes,
97 ..._pan.nodes,
98 ]);
99 }
102 void update(DemoContext context, double dt) {
103 if (orbiting) angle = (angle + 25.0 * dt + 180.0) % 360.0 - 180.0;
104 final double a = angle * math.pi / 180.0;
105 final Vector3 at = Vector3(
106 distance * math.cos(a),
107 0.0,
108 distance * math.sin(a),
109 );
110 // Facing the source, or a quarter turn from it.
111 final double toward = math.atan2(at.x, at.z);
112 final double yaw = toward + (turnedAway ? math.pi / 2 : 0.0);
113 _listener.aimAt(at, yaw);
114 _audio.update(_listener);
115 _head
116 ..setPosition(at.x, 0.35, at.z)
117 ..setRotationYawPitchRoll(yaw, 0.0, 0.0);
118 final double loud = _emitter.audibleGain;
119 _source.setUniformScale(0.6 + 0.9 * loud);
120 _source.setPosition(0.0, 0.5 * (0.6 + 0.9 * loud), 0.0);
121 _gain.set(loud);
122 _pan.set(_emitter.pan);
123 }
126 List<DemoControl> controls(DemoContext context) => <DemoControl>[
127 SliderControl(
128 'Distance',
129 min: 0.5,
130 max: 12.0,
131 value: () => distance,
132 onChanged: (double v) => distance = v,
133 format: (double v) => '${v.toStringAsFixed(1)} m',
134 ),
135 SliderControl(
136 'Angle',
137 min: -180.0,
138 max: 180.0,
139 value: () => angle,
140 onChanged: (double v) {
141 orbiting = false;
142 angle = v;
143 },
144 format: (double v) => '${v.round()}°',
145 ),
146 ToggleControl(
147 'Walk round it',
148 value: () => orbiting,
149 onChanged: (bool v) => orbiting = v,
150 ),
151 ToggleControl(
152 'Turn away',
153 value: () => turnedAway,
154 onChanged: (bool v) => turnedAway = v,
155 ),
156 ];
158 static String _run() {
159 final backend = SilentBackend();
160 final scene = AudioScene(backend: backend);
161 const bell = SoundDef(name: 'bell', asset: 'bell.wav');
163 final emitter = scene.play(bell, Vector3(4.0, 0.0, 0.0));
164 final listener = AudioListener()..aimAt(Vector3.zero(), 0.0);
165 scene.update(listener);
167 final farGain = emitter.audibleGain;
168 final farPan = emitter.pan;
170 // The listener turns to face the source directly and steps closer.
171 listener.aimAt(Vector3(2.0, 0.0, 0.0), -1.5707963267948966);
172 scene.update(listener);
174 return 'four metres to the side: gain ${farGain.toStringAsFixed(3)}, '
175 'pan ${farPan.toStringAsFixed(2)}\n'
176 'two metres away and facing it: gain '
177 '${emitter.audibleGain.toStringAsFixed(3)}, pan '
178 '${emitter.pan.toStringAsFixed(2)}';
179 }
182 void verify(Scene scene, FrameResult frame) {
183 if (frame.drawCalls < 1) {
184 throw StateError('the source marker was not drawn');
185 }
186 if (!_report.contains('pan 1.00')) {
187 throw StateError(
188 'a source straight to the right of the listener '
189 'should pan hard right',
190 );
191 }
192 }
193}